From 15fff4c164cedaf6ab54c1548c03de3856e90e9a Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 25 Aug 2026 18:43:19 +0200 Subject: [PATCH 01/40] [client] Sweep connections on network loss via a shared netevents manager (#7254) Losing the last network only flipped the availability state: the dead management, signal and relay sockets stayed silently connected until their own timeouts, so the client kept reporting Connected with no network at all. Introduce client/netevents with a Manager that ties the availability state, the connection sweeper and the status recorder together, and move the netstate and netsweep packages under it (netsweep renamed to sweep). SetNetworkAvailable(false) now also sweeps the registered connections so their owners redial and the listener reaches the NoNetwork state. The Android and iOS bindings own a Manager instance and inject it through the constructors; consumers hold the concrete *Manager whose nil zero value reports always-online and never sweeps, with interfaces kept only as parameter contracts. The relay guard settle wait moved into the Manager as WaitSettled, removing the netevents import from the relay package. --- client/android/client.go | 35 ++-- client/grpc/dialer_generic.go | 9 +- client/grpc/dialer_js.go | 11 +- client/grpc/retry.go | 17 +- client/grpc/retry_test.go | 2 +- client/internal/connect.go | 40 ++-- client/internal/engine.go | 16 +- client/internal/peer/conn.go | 8 +- client/internal/peer/guard/guard.go | 29 +-- .../peer/guard/guard_netstate_test.go | 2 +- client/ios/NetBirdSDK/client.go | 31 ++-- client/netevents/netevents.go | 173 ++++++++++++++++++ client/netevents/netevents_test.go | 34 ++++ client/{ => netevents}/netstate/netstate.go | 0 .../{ => netevents}/netstate/netstate_test.go | 0 .../sweep}/quick_retry.go | 4 +- .../sweep}/quick_retry_test.go | 2 +- .../netsweep.go => netevents/sweep/sweep.go} | 8 +- .../sweep/sweep_test.go} | 2 +- shared/management/client/grpc.go | 37 ++-- shared/relay/client/client.go | 24 ++- shared/relay/client/guard.go | 103 ++++------- shared/relay/client/guard_test.go | 30 --- shared/relay/client/manager.go | 23 +-- shared/relay/client/picker.go | 5 +- shared/signal/client/grpc.go | 37 ++-- 26 files changed, 418 insertions(+), 264 deletions(-) create mode 100644 client/netevents/netevents.go create mode 100644 client/netevents/netevents_test.go rename client/{ => netevents}/netstate/netstate.go (100%) rename client/{ => netevents}/netstate/netstate_test.go (100%) rename client/{netsweep => netevents/sweep}/quick_retry.go (90%) rename client/{netsweep => netevents/sweep}/quick_retry_test.go (99%) rename client/{netsweep/netsweep.go => netevents/sweep/sweep.go} (96%) rename client/{netsweep/netsweep_test.go => netevents/sweep/sweep_test.go} (99%) delete mode 100644 shared/relay/client/guard_test.go 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/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/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/engine.go b/client/internal/engine.go index 7f3f8185f..fac5224c8 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, @@ -1902,8 +1902,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{ 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/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index f92f085ab..8373e498a 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -22,8 +22,7 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/netstate" - "github.com/netbirdio/netbird/client/netsweep" + "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -84,12 +83,10 @@ type Client struct { onHostDnsFn func([]string) dnsManager dns.IosDnsManager loginComplete bool - // netState outlives engine restarts: it mirrors the OS connectivity, not - // the engine lifecycle. Run injects it into each new ConnectClient, which - // distributes it to every reconnection loop. - netState *netstate.State - // sweeper also outlives engine restarts; NotifyNetworkChange sweeps it. - sweeper *netsweep.Sweeper + // netMgr outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run injects its state and sweeper into each new + // ConnectClient. + netMgr *netevents.Manager // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config @@ -100,6 +97,7 @@ type Client struct { // NewClient instantiate a new Client func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { + recorder := peer.NewRecorder("") return &Client{ cfgFile: cfgFile, stateFile: stateFile, @@ -108,12 +106,11 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV deviceName: deviceName, osName: osName, osVersion: osVersion, - recorder: peer.NewRecorder(""), + recorder: recorder, ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, dnsManager: dnsManager, - netState: netstate.New(), - sweeper: netsweep.New(), + netMgr: netevents.NewManager(recorder), } } @@ -190,7 +187,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { cfg.WgIface = interfaceName connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, - internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) + internal.WithNetEvents(c.netMgr)) c.setState(cfg, connectClient) // Persist the latest sync response so DebugBundle can include the network // map. On iOS this is backed by disk to keep it out of the constrained @@ -203,10 +200,11 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { // (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops // suspend their attempts and the connection listener reports NoNetwork // instead of Connecting; when availability returns, the loops resume -// immediately with a fresh backoff. +// immediately with a fresh backoff. Losing the last network also sweeps the +// registered connections, so the client does not keep reporting Connected +// over stale sockets with no network at all. func (c *Client) SetNetworkAvailable(available bool) { - c.netState.Set(available) - c.recorder.SetNetworkAvailable(available) + c.netMgr.SetNetworkAvailable(available) } // NotifyNetworkChange marks the management, signal and relay connections @@ -214,8 +212,7 @@ func (c *Client) SetNetworkAvailable(available bool) { // whatever has not redialed on the new network by then. The engine and the // TUN device stay untouched. func (c *Client) NotifyNetworkChange() { - c.sweeper.MarkNetworkChange() - log.Infof("network change: connections marked stale") + c.netMgr.NotifyNetworkChange() } // Stop the internal client and free the resources diff --git a/client/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/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/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 From ccf8f43cb1c4a5be497f9d725ae673ace2bdd5c7 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:15:16 +0900 Subject: [PATCH 02/40] [client] Ask the OS for privileges when a guarded SSH setting is changed (#7066) --- .goreleaser_ui.yaml | 10 + client/internal/daemonaddr/identity.go | 17 + client/internal/daemonaddr/identity_test.go | 29 ++ client/internal/elevate/elevate.go | 74 ++++ client/internal/elevate/output.go | 18 + client/internal/elevate/output_test.go | 21 + client/internal/elevate/run_darwin.go | 359 ++++++++++++++++++ client/internal/elevate/run_darwin_test.go | 111 ++++++ client/internal/elevate/run_unix.go | 117 ++++++ client/internal/elevate/run_unix_test.go | 110 ++++++ client/internal/elevate/run_unsupported.go | 19 + client/internal/elevate/run_windows.go | 187 +++++++++ client/internal/elevate/trusted.go | 40 ++ .../internal/elevate/trusted_group_darwin.go | 10 + client/internal/elevate/trusted_group_unix.go | 9 + client/internal/elevate/trusted_unix.go | 119 ++++++ client/internal/elevate/trusted_unix_test.go | 148 ++++++++ client/internal/elevate/trusted_windows.go | 215 +++++++++++ .../internal/elevate/trusted_windows_test.go | 126 ++++++ client/internal/getent/cgo_unix.go | 36 ++ client/internal/getent/getent.go | 6 + .../server => internal/getent}/getent_test.go | 105 +++-- client/internal/getent/nocgo_unix.go | 110 ++++++ client/internal/getent/unix.go | 224 +++++++++++ .../getent/unix_test.go} | 248 +++++++----- client/internal/getent/windows.go | 36 ++ client/internal/ipcauth/privileged.go | 16 + client/ssh/server/getent_cgo_unix.go | 24 -- client/ssh/server/getent_nocgo_unix.go | 74 ---- client/ssh/server/getent_unix.go | 127 ------- client/ssh/server/getent_windows.go | 26 -- client/ssh/server/shell.go | 8 +- client/ssh/server/shell_unix_test.go | 94 +++++ client/ssh/server/user_utils.go | 6 +- client/ssh/server/userswitching_unix.go | 4 +- client/ui/build/linux/netbird.desktop | 3 +- .../linux/polkit/io.netbird.settings.policy | 47 +++ .../frontend/src/contexts/SettingsContext.tsx | 100 ++++- client/ui/frontend/src/hooks/usePrivilege.ts | 2 +- .../src/modules/settings/SettingsSSH.tsx | 157 ++++++-- client/ui/i18n/locales/de/common.json | 25 +- client/ui/i18n/locales/en/common.json | 28 +- client/ui/i18n/locales/es/common.json | 25 +- client/ui/i18n/locales/fr/common.json | 25 +- client/ui/i18n/locales/hu/common.json | 25 +- client/ui/i18n/locales/it/common.json | 25 +- client/ui/i18n/locales/ja/common.json | 19 +- client/ui/i18n/locales/pt/common.json | 25 +- client/ui/i18n/locales/ru/common.json | 25 +- client/ui/i18n/locales/zh-CN/common.json | 25 +- client/ui/main.go | 9 + client/ui/privileged_settings.go | 27 ++ client/ui/services/guarded.go | 231 +++++++++++ client/ui/services/guarded_test.go | 355 +++++++++++++++++ client/ui/services/oneshot.go | 239 ++++++++++++ client/ui/services/oneshot_test.go | 151 ++++++++ client/ui/services/settings.go | 147 ++++++- 57 files changed, 4075 insertions(+), 523 deletions(-) create mode 100644 client/internal/daemonaddr/identity.go create mode 100644 client/internal/daemonaddr/identity_test.go create mode 100644 client/internal/elevate/elevate.go create mode 100644 client/internal/elevate/output.go create mode 100644 client/internal/elevate/output_test.go create mode 100644 client/internal/elevate/run_darwin.go create mode 100644 client/internal/elevate/run_darwin_test.go create mode 100644 client/internal/elevate/run_unix.go create mode 100644 client/internal/elevate/run_unix_test.go create mode 100644 client/internal/elevate/run_unsupported.go create mode 100644 client/internal/elevate/run_windows.go create mode 100644 client/internal/elevate/trusted.go create mode 100644 client/internal/elevate/trusted_group_darwin.go create mode 100644 client/internal/elevate/trusted_group_unix.go create mode 100644 client/internal/elevate/trusted_unix.go create mode 100644 client/internal/elevate/trusted_unix_test.go create mode 100644 client/internal/elevate/trusted_windows.go create mode 100644 client/internal/elevate/trusted_windows_test.go create mode 100644 client/internal/getent/cgo_unix.go create mode 100644 client/internal/getent/getent.go rename client/{ssh/server => internal/getent}/getent_test.go (53%) create mode 100644 client/internal/getent/nocgo_unix.go create mode 100644 client/internal/getent/unix.go rename client/{ssh/server/getent_unix_test.go => internal/getent/unix_test.go} (63%) create mode 100644 client/internal/getent/windows.go delete mode 100644 client/ssh/server/getent_cgo_unix.go delete mode 100644 client/ssh/server/getent_nocgo_unix.go delete mode 100644 client/ssh/server/getent_unix.go delete mode 100644 client/ssh/server/getent_windows.go create mode 100644 client/ssh/server/shell_unix_test.go create mode 100644 client/ui/build/linux/polkit/io.netbird.settings.policy create mode 100644 client/ui/privileged_settings.go create mode 100644 client/ui/services/guarded.go create mode 100644 client/ui/services/guarded_test.go create mode 100644 client/ui/services/oneshot.go create mode 100644 client/ui/services/oneshot_test.go diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 1c5bc41ac..24903188f 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -92,6 +92,11 @@ nfpms: dst: /usr/share/applications/org.wails.netbird.desktop - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png + # Names the polkit action for the elevation prompt the app raises when an + # unprivileged user changes a privileged setting; without it the dialog + # shows a raw command line. + - src: client/ui/build/linux/polkit/io.netbird.settings.policy + dst: /usr/share/polkit-1/actions/io.netbird.settings.policy dependencies: - netbird (>= 0.75.0) - libgtk-4-1 (>= 4.14) @@ -116,6 +121,11 @@ nfpms: dst: /usr/share/applications/org.wails.netbird.desktop - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png + # Names the polkit action for the elevation prompt the app raises when an + # unprivileged user changes a privileged setting; without it the dialog + # shows a raw command line. + - src: client/ui/build/linux/polkit/io.netbird.settings.policy + dst: /usr/share/polkit-1/actions/io.netbird.settings.policy dependencies: - netbird >= 0.75.0 - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14) diff --git a/client/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/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/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/ssh/server/getent_cgo_unix.go b/client/ssh/server/getent_cgo_unix.go deleted file mode 100644 index 4afbfc627..000000000 --- a/client/ssh/server/getent_cgo_unix.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build cgo && !osusergo && !windows - -package server - -import "os/user" - -// lookupWithGetent with CGO delegates directly to os/user.Lookup. -// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through -// the NSS stack natively. If it fails, the user truly doesn't exist and -// getent would also fail. -func lookupWithGetent(username string) (*user.User, error) { - return user.Lookup(username) -} - -// currentUserWithGetent with CGO delegates directly to os/user.Current. -func currentUserWithGetent() (*user.User, error) { - return user.Current() -} - -// groupIdsWithFallback with CGO delegates directly to user.GroupIds. -// libc's getgrouplist handles NSS groups natively. -func groupIdsWithFallback(u *user.User) ([]string, error) { - return u.GroupIds() -} diff --git a/client/ssh/server/getent_nocgo_unix.go b/client/ssh/server/getent_nocgo_unix.go deleted file mode 100644 index 314daae4c..000000000 --- a/client/ssh/server/getent_nocgo_unix.go +++ /dev/null @@ -1,74 +0,0 @@ -//go:build (!cgo || osusergo) && !windows - -package server - -import ( - "os" - "os/user" - "strconv" - - log "github.com/sirupsen/logrus" -) - -// lookupWithGetent looks up a user by name, falling back to getent if os/user fails. -// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users. -// getent goes through the host's NSS stack. -func lookupWithGetent(username string) (*user.User, error) { - u, err := user.Lookup(username) - if err == nil { - return u, nil - } - - stdErr := err - log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err) - - u, _, getentErr := runGetent(username) - if getentErr != nil { - log.Debugf("getent fallback for %q also failed: %v", username, getentErr) - return nil, stdErr - } - - return u, nil -} - -// currentUserWithGetent gets the current user, falling back to getent if os/user fails. -func currentUserWithGetent() (*user.User, error) { - u, err := user.Current() - if err == nil { - return u, nil - } - - stdErr := err - uid := strconv.Itoa(os.Getuid()) - log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err) - - u, _, getentErr := runGetent(uid) - if getentErr != nil { - return nil, stdErr - } - - return u, nil -} - -// groupIdsWithFallback gets group IDs for a user via the id command first, -// falling back to user.GroupIds(). -// NOTE: unlike lookupWithGetent/currentUserWithGetent which try stdlib first, -// this intentionally tries `id -G` first because without CGO, user.GroupIds() -// only reads /etc/group and silently returns incomplete results for NSS users -// (no error, just missing groups). The id command goes through NSS and returns -// the full set. -func groupIdsWithFallback(u *user.User) ([]string, error) { - ids, err := runIdGroups(u.Username) - if err == nil { - return ids, nil - } - - log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err) - - ids, stdErr := u.GroupIds() - if stdErr != nil { - return nil, stdErr - } - - return ids, nil -} diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go deleted file mode 100644 index a3a9641f8..000000000 --- a/client/ssh/server/getent_unix.go +++ /dev/null @@ -1,127 +0,0 @@ -//go:build !windows - -package server - -import ( - "context" - "fmt" - "os/exec" - "os/user" - "runtime" - "strings" - "time" -) - -const getentTimeout = 5 * time.Second - -// getShellFromGetent gets a user's login shell via getent by UID. -// This is needed even with CGO because getShellFromPasswd reads /etc/passwd -// directly and won't find NSS-provided users there. -func getShellFromGetent(userID string) string { - _, shell, err := runGetent(userID) - if err != nil { - return "" - } - return shell -} - -// runGetent executes `getent passwd ` and returns the user and login shell. -func runGetent(query string) (*user.User, string, error) { - if !validateGetentInput(query) { - return nil, "", fmt.Errorf("invalid getent input: %q", query) - } - - ctx, cancel := context.WithTimeout(context.Background(), getentTimeout) - defer cancel() - - out, err := exec.CommandContext(ctx, "getent", "passwd", query).Output() - if err != nil { - return nil, "", fmt.Errorf("getent passwd %s: %w", query, err) - } - - return parseGetentPasswd(string(out)) -} - -// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell" -func parseGetentPasswd(output string) (*user.User, string, error) { - fields := strings.SplitN(strings.TrimSpace(output), ":", 8) - if len(fields) < 6 { - return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output) - } - - if fields[0] == "" || fields[2] == "" || fields[3] == "" { - return nil, "", fmt.Errorf("missing required fields in getent output: %q", output) - } - - var shell string - if len(fields) >= 7 { - shell = fields[6] - } - - return &user.User{ - Username: fields[0], - Uid: fields[2], - Gid: fields[3], - Name: fields[4], - HomeDir: fields[5], - }, shell, nil -} - -// validateGetentInput checks that the input is safe to pass to getent or id. -// Allows POSIX usernames, numeric UIDs, and common NSS extensions -// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is -// rejected so the input can never be parsed as a command-line flag. -func validateGetentInput(input string) bool { - maxLen := 32 - if runtime.GOOS == "linux" { - maxLen = 256 - } - - if len(input) == 0 || len(input) > maxLen { - return false - } - - if input[0] == '-' { - return false - } - - for _, r := range input { - if isAllowedGetentChar(r) { - continue - } - return false - } - return true -} - -func isAllowedGetentChar(r rune) bool { - if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { - return true - } - switch r { - case '.', '_', '-', '@', '+', '$': - return true - } - return false -} - -// runIdGroups runs `id -G ` and returns the space-separated group IDs. -func runIdGroups(username string) ([]string, error) { - if !validateGetentInput(username) { - return nil, fmt.Errorf("invalid username for id command: %q", username) - } - - ctx, cancel := context.WithTimeout(context.Background(), getentTimeout) - defer cancel() - - out, err := exec.CommandContext(ctx, "id", "-G", username).Output() - if err != nil { - return nil, fmt.Errorf("id -G %s: %w", username, err) - } - - trimmed := strings.TrimSpace(string(out)) - if trimmed == "" { - return nil, fmt.Errorf("id -G %s: empty output", username) - } - return strings.Fields(trimmed), nil -} diff --git a/client/ssh/server/getent_windows.go b/client/ssh/server/getent_windows.go deleted file mode 100644 index 3e76b3e8e..000000000 --- a/client/ssh/server/getent_windows.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build windows - -package server - -import "os/user" - -// lookupWithGetent on Windows just delegates to os/user.Lookup. -// Windows does not use NSS/getent; its user lookup works without CGO. -func lookupWithGetent(username string) (*user.User, error) { - return user.Lookup(username) -} - -// currentUserWithGetent on Windows just delegates to os/user.Current. -func currentUserWithGetent() (*user.User, error) { - return user.Current() -} - -// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection. -func getShellFromGetent(_ string) string { - return "" -} - -// groupIdsWithFallback on Windows just delegates to u.GroupIds(). -func groupIdsWithFallback(u *user.User) ([]string, error) { - return u.GroupIds() -} diff --git a/client/ssh/server/shell.go b/client/ssh/server/shell.go index 1e8ff5e31..7b356b2a0 100644 --- a/client/ssh/server/shell.go +++ b/client/ssh/server/shell.go @@ -13,6 +13,8 @@ import ( "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) const ( @@ -56,7 +58,11 @@ func getUnixUserShell(userID string) string { return shell } - if shell := getShellFromGetent(userID); shell != "" { + shell, err := getent.UserShell(userID) + if err != nil { + log.Debugf("look up the shell for uid %s through getent: %v", userID, err) + } + if shell != "" { return shell } diff --git a/client/ssh/server/shell_unix_test.go b/client/ssh/server/shell_unix_test.go new file mode 100644 index 000000000..c5e65e535 --- /dev/null +++ b/client/ssh/server/shell_unix_test.go @@ -0,0 +1,94 @@ +//go:build !windows + +package server + +import ( + "os/exec" + "os/user" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/getent" +) + +// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly +// reads the current user's shell from /etc/passwd by comparing it against what +// getent reports (which goes through NSS). +func TestGetShellFromPasswd_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + shell := getShellFromPasswd(current.Uid) + if shell == "" { + t.Skip("current user not found in /etc/passwd (may be an NSS-only user)") + } + + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) + + if _, err := exec.LookPath("getent"); err == nil { + getentShell, getentErr := getent.UserShell(current.Uid) + if getentErr == nil && getentShell != "" { + assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent") + } + } +} + +// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read +// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on +// any standard Unix system. +func TestGetShellFromPasswd_RootUser(t *testing.T) { + shell := getShellFromPasswd("0") + require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd") + assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell) +} + +// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd +// returns empty for a UID that doesn't exist in /etc/passwd. +func TestGetShellFromPasswd_NonexistentUID(t *testing.T) { + shell := getShellFromPasswd("4294967294") + assert.Empty(t, shell, "nonexistent UID should return empty shell") +} + +// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly +// and cross-validates every entry against getent to ensure the two shell +// sources agree. +func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available") + } + + // Pick a few well-known system UIDs that are virtually always in /etc/passwd. + uids := []string{"0"} // root + + current, err := user.Current() + require.NoError(t, err) + uids = append(uids, current.Uid) + + for _, uid := range uids { + passwdShell := getShellFromPasswd(uid) + if passwdShell == "" { + continue + } + + getentShell, err := getent.UserShell(uid) + if err != nil { + continue + } + + assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid) + } +} + +// TestIntegration_ShellLookupChain tests the full shell resolution chain +// (getShellFromPasswd -> getent -> $SHELL -> default). +func TestIntegration_ShellLookupChain(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + // getUserShell is the top-level function used by the SSH server. + shell := getUserShell(current.Uid) + require.NotEmpty(t, shell, "getUserShell must always return a shell") + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) +} diff --git a/client/ssh/server/user_utils.go b/client/ssh/server/user_utils.go index 6c8142b30..f2f33b3d7 100644 --- a/client/ssh/server/user_utils.go +++ b/client/ssh/server/user_utils.go @@ -9,6 +9,8 @@ import ( "strings" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) var ( @@ -18,8 +20,8 @@ var ( // Dependency injection variables for testing - allows mocking dynamic runtime checks var ( - getCurrentUser = currentUserWithGetent - lookupUser = lookupWithGetent + getCurrentUser = getent.CurrentUser + lookupUser = getent.LookupUser getCurrentOS = func() string { return runtime.GOOS } getIsProcessPrivileged = isCurrentProcessPrivileged diff --git a/client/ssh/server/userswitching_unix.go b/client/ssh/server/userswitching_unix.go index 220e2240f..ae60ec64c 100644 --- a/client/ssh/server/userswitching_unix.go +++ b/client/ssh/server/userswitching_unix.go @@ -16,6 +16,8 @@ import ( "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) // POSIX portable filename character set regex: [a-zA-Z0-9._-] @@ -160,7 +162,7 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u // getSupplementaryGroups retrieves supplementary group IDs for a user. // Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds. func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) { - groupIDStrings, err := groupIdsWithFallback(u) + groupIDStrings, err := getent.GroupIDs(u) if err != nil { return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err) } diff --git a/client/ui/build/linux/netbird.desktop b/client/ui/build/linux/netbird.desktop index a81f3698a..0d43b62a2 100644 --- a/client/ui/build/linux/netbird.desktop +++ b/client/ui/build/linux/netbird.desktop @@ -1,5 +1,6 @@ [Desktop Entry] -Name=Netbird +Name=NetBird +Comment=NetBird desktop client Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui Icon=netbird Type=Application diff --git a/client/ui/build/linux/polkit/io.netbird.settings.policy b/client/ui/build/linux/polkit/io.netbird.settings.policy new file mode 100644 index 000000000..e12f1ddc7 --- /dev/null +++ b/client/ui/build/linux/polkit/io.netbird.settings.policy @@ -0,0 +1,47 @@ + + + + + + NetBird + https://netbird.io + + + Change privileged NetBird settings + Authentication is required to change NetBird settings that grant SSH access to this computer. + netbird + + auth_admin + auth_admin + auth_admin + + /usr/bin/netbird-ui + --apply-privileged-settings + + + + Change privileged NetBird settings + Authentication is required to change NetBird settings that grant SSH access to this computer. + netbird + + auth_admin + auth_admin + auth_admin + + /usr/local/bin/netbird-ui + --apply-privileged-settings + + diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx index 3f4b2d0d2..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/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 1208a37fe..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" }, @@ -1351,13 +1351,28 @@ "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, aber zum erneuten Aktivieren sind {actor} erforderlich:" + "message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich." }, "settings.ssh.privilege.oneWayInverted": { - "message": "Sie können dies aktivieren, aber zum erneuten Deaktivieren sind {actor} erforderlich:" + "message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich." + }, + "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 6dc4ffd0b..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" }, @@ -1351,13 +1351,28 @@ "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}:" + "message": "Puede desactivarlo, pero volver a activarlo requiere {actor}." }, "settings.ssh.privilege.oneWayInverted": { - "message": "Puede activarlo, pero volver a desactivarlo requiere {actor}:" + "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 d3e54440c..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" }, @@ -1351,13 +1351,28 @@ "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} :" + "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} :" + "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 19aede17f..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" }, @@ -1351,13 +1351,28 @@ "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:" + "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:" + "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 dab9e0cb4..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" }, @@ -1351,13 +1351,28 @@ "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}:" + "message": "Può disabilitarlo, ma riabilitarlo richiede {actor}." }, "settings.ssh.privilege.oneWayInverted": { - "message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}:" + "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 246c232a8..6ffe05e1c 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1351,13 +1351,28 @@ "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}が必要です:" + "message": "無効にはできますが、再度有効にするには{actor}が必要です。" }, "settings.ssh.privilege.oneWayInverted": { - "message": "有効にはできますが、再度無効にするには{actor}が必要です:" + "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 418e93717..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" }, @@ -1351,13 +1351,28 @@ "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}:" + "message": "Você pode desativar isto, mas ativar novamente requer {actor}." }, "settings.ssh.privilege.oneWayInverted": { - "message": "Você pode ativar isto, mas desativar novamente requer {actor}:" + "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 958b5a21c..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": "Общие" }, @@ -1351,13 +1351,28 @@ "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}:" + "message": "Отключить можно, но чтобы включить снова, нужны {actor}." }, "settings.ssh.privilege.oneWayInverted": { - "message": "Включить можно, но чтобы отключить снова, нужны {actor}:" + "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 90ae5e003..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": "常规" }, @@ -1351,13 +1351,28 @@ "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}:" + "message": "您可以关闭此项,但重新开启需要{actor}。" }, "settings.ssh.privilege.oneWayInverted": { - "message": "您可以开启此项,但再次关闭需要{actor}:" + "message": "您可以开启此项,但再次关闭需要{actor}。" + }, + "settings.ssh.privilege.authorizePending": { + "message": "正在等待授权…" } } diff --git a/client/ui/main.go b/client/ui/main.go index e20bfe074..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) 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 { From 51095cb9865a823b126ab464be02b4910f22b73a Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:33:51 +0900 Subject: [PATCH 03/40] [client, management] Support per-peer lazy connection state and default proxy peers to lazy (#6762) * Support per-peer lazy connection state and default proxy peers to lazy * Classify forward targets from incoming config in lazy exclusion * Set IsUserspaceBind mock so lazy manager starts in engine test * Skip lazy exclude reconciliation when the set is unchanged * Keep cached lazy flag when a sync carries no peer config --- client/internal/conn_mgr.go | 149 +- client/internal/conn_mgr_test.go | 88 + client/internal/engine.go | 88 +- client/internal/engine_lazy_exclude_test.go | 6 +- client/internal/engine_test.go | 3 +- .../shared/grpc/components_encoder.go | 1 + .../shared/grpc/components_encoder_test.go | 6 +- .../grpc/components_envelope_response.go | 8 +- .../internals/shared/grpc/conversion.go | 5 +- management/server/peer/peer.go | 1 + shared/management/networkmap/decode.go | 1 + shared/management/networkmap/encode.go | 18 +- shared/management/networkmap/envelope.go | 4 +- shared/management/proto/management.pb.go | 1952 +++++++++-------- shared/management/proto/management.proto | 21 + shared/management/types/component_types.go | 3 + 16 files changed, 1281 insertions(+), 1073 deletions(-) diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index ad0f00c5d..2b9e32130 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 @@ -42,6 +44,9 @@ type ConnMgr struct { iface lazyconn.WGIface force lazyForce rosenpassEnabled bool + // 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 +58,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 @@ -75,69 +84,56 @@ func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerSto 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 is the only condition that disables it. 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") + log.Warnf("rosenpass 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 +153,13 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) { return } + // The exclude set is recomputed every sync but rarely changes; skip the O(n) + // store lookups and reconciliation when it matches what was already applied. + if maps.Equal(peerIDs, e.appliedExcludeList) { + return + } + e.appliedExcludeList = maps.Clone(peerIDs) + excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs)) for peerID := range peerIDs { @@ -192,12 +195,16 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) { } } -func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn) (exists bool) { +// AddPeerConn registers a peer connection. permanent requests an always-active connection +// (the peer belongs to the exclude set: a forwarder, or a peer that is not lazy by policy). +// Non-permanent peers are handed to the lazy manager. The subsequent SetExcludeList call +// reconciles membership for existing peers across flag flips. +func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, permanent bool) (exists bool) { if success := e.peerStore.AddPeerConn(peerKey, conn); !success { return true } - if !e.isStartedWithLazyMgr() { + if !e.isStartedWithLazyMgr() || permanent { if err := conn.Open(ctx); err != nil { conn.Log.Errorf("failed to open connection: %v", err) } @@ -296,6 +303,8 @@ func (e *ConnMgr) Close() { e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil e.lazyConnMgrMu.Unlock() + + e.appliedExcludeList = nil } func (e *ConnMgr) initLazyManager(engineCtx context.Context) { @@ -309,6 +318,8 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) { e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx) e.lazyConnMgrMu.Unlock() + e.appliedExcludeList = nil + e.wg.Add(1) go func() { defer e.wg.Done() @@ -316,46 +327,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..6711c6e54 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(nil, 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/engine.go b/client/internal/engine.go index fac5224c8..389418c25 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -833,7 +833,7 @@ func (e *Engine) blockLanAccess() { // modifyPeers updates peers that have been modified (e.g. IP address has been changed). // It closes the existing connection, removes it from the peerConns map, and creates a new one. -func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { +func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig, forwardingRules []firewallManager.ForwardRule) error { // first, check if peers have been modified var modified []*mgmProto.RemotePeerConfig @@ -872,7 +872,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { } // third, add the peer connections again for _, p := range modified { - err := e.addNewPeer(p) + err := e.addNewPeer(p, forwardingRules) if err != nil { return err } @@ -1495,8 +1495,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 { @@ -1574,15 +1578,14 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.updateOfflinePeers(networkMap.GetOfflinePeers()) done() - remotePeers, err := e.reconcilePeers(networkMap) + remotePeers, err := e.reconcilePeers(networkMap, forwardingRules) if err != nil { return err } // 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(forwardingRules, remotePeers)) done() e.networkSerial = serial @@ -1592,8 +1595,10 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // reconcilePeers applies the remote peer list from the network map (removing, // modifying and adding peers, then updating SSH config) and returns the remote -// peers with our own peer filtered out, for use by later sync steps. -func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) { +// peers with our own peer filtered out, for use by later sync steps. The +// forwarding rules are used to decide whether a newly added peer needs an +// always-active connection. +func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap, forwardingRules []firewallManager.ForwardRule) ([]*mgmProto.RemotePeerConfig, error) { // Filter out own peer from the remote peers list localPubKey := e.config.WgPrivateKey.PublicKey().String() remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers())) @@ -1621,14 +1626,14 @@ func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.Re } done = e.phase("modified_peers") - err = e.modifyPeers(remotePeers) + err = e.modifyPeers(remotePeers, forwardingRules) done() if err != nil { return nil, err } done = e.phase("added_peers") - err = e.addNewPeers(remotePeers) + err = e.addNewPeers(remotePeers, forwardingRules) done() if err != nil { return nil, err @@ -1824,9 +1829,9 @@ 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 { +func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig, forwardingRules []firewallManager.ForwardRule) error { for _, p := range peersUpdate { - err := e.addNewPeer(p) + err := e.addNewPeer(p, forwardingRules) if err != nil { return err } @@ -1834,8 +1839,9 @@ func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { return nil } -// addNewPeer add peer if connection doesn't exist -func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { +// addNewPeer add peer if connection doesn't exist. A peer that is not lazy by +// policy (or is a forwarder) gets an always-active connection instead. +func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig, forwardingRules []firewallManager.ForwardRule) error { peerKey := peerConfig.GetWgPubKey() peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps())) if _, ok := e.peerStore.PeerConn(peerKey); ok { @@ -1869,7 +1875,7 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err) } - if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn); exists { + if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, e.isPermanentPeer(peerConfig, forwardingRules)); exists { conn.Close(false) return fmt.Errorf("peer already exists: %s", peerKey) } @@ -2661,34 +2667,44 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal return forwardingRules, nberrors.FormatErrorOrNil(merr) } +// toExcludedLazyPeers returns the peers that must have an always-active +// connection, so the caller can reconcile the lazy manager's exclude list. func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, 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.isPermanentPeer(p, rules) { + 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 +// isPermanentPeer reports whether a peer needs an always-active connection: it +// is not lazy by policy (the per-peer lazy hint or account flag, subject to the +// local override), or it is an ingress forward target. Inbound forwarded traffic +// is initiated remotely and cannot wake a lazy connection, so the peer routing +// the target must stay permanently connected. +func (e *Engine) isPermanentPeer(p *mgmProto.RemotePeerConfig, rules []firewallManager.ForwardRule) bool { + if !e.connMgr.PeerLazyDefault(p.GetLazyState()) { + return true } - return prefixesContain(prefixes, addr) + + // Match against the incoming config's AllowedIPs rather than the peer store: + // isPermanentPeer runs in addNewPeer before the peer is in the store, so a + // store lookup would miss a forward target and register it as lazy. + prefixes := make([]netip.Prefix, 0, len(p.GetAllowedIps())) + for _, ipStr := range p.GetAllowedIps() { + if prefix, err := netip.ParsePrefix(ipStr); err == nil { + prefixes = append(prefixes, prefix) + } + } + for _, r := range rules { + if prefixesContain(prefixes, r.TranslatedAddress) { + log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) + return true + } + } + return false } // prefixesContain reports whether addr falls within any of the prefixes. diff --git a/client/internal/engine_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go index b5ef16c3b..815db2596 100644 --- a/client/internal/engine_lazy_exclude_test.go +++ b/client/internal/engine_lazy_exclude_test.go @@ -49,7 +49,8 @@ func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) { 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} + // Lazy on for normal peers, so the only exclusion under test is the forward target. + e := &Engine{peerStore: store, connMgr: &ConnMgr{force: lazyForceOn}} peers := []*mgmProto.RemotePeerConfig{ {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}}, @@ -67,7 +68,8 @@ func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) { } func TestToExcludedLazyPeers_NoRules(t *testing.T) { - e := &Engine{peerStore: peerstore.NewConnStore()} + // Lazy on for normal peers and no forward rules, so nothing is excluded. + e := &Engine{peerStore: peerstore.NewConnStore(), connMgr: &ConnMgr{force: lazyForceOn}} peers := []*mgmProto.RemotePeerConfig{ {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}}, diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index fbd47ed74..4e9faa437 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -279,7 +279,8 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { }, MobileDependency{}) wgIface := &MockWGIface{ - NameFunc: func() string { return "utun102" }, + NameFunc: func() string { return "utun102" }, + IsUserspaceBindFunc: func() bool { return true }, RemovePeerFunc: func(peerKey string) error { return nil }, diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index e1a5cae48..baf21af94 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -703,6 +703,7 @@ func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact { SupportsIpv6: p.SupportsIPv6, SupportsSourcePrefixes: p.SupportsSourcePrefixes, ServerSshAllowed: p.ServerSSHAllowed, + ProxyEmbedded: p.ProxyEmbedded, } if !p.LastLogin.IsZero() { pc.LastLoginUnixNano = p.LastLogin.UnixNano() diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index f7df82f2f..f7a27ceba 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -684,8 +684,8 @@ func TestEncodeNetworkMapEnvelope_GroupIDToUserIDs(t *testing.T) { } 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") } @@ -700,7 +700,7 @@ func TestToProxyPatch_PopulatesAllFields(t *testing.T) { }}, } - 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) diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go index 820708c98..88fa4a22d 100644 --- a/management/internals/shared/grpc/components_envelope_response.go +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -66,7 +66,7 @@ func ToComponentSyncResponse( DNSDomain: dnsName, DNSForwarderPort: dnsFwdPort, UserIDClaim: userIDClaim, - ProxyPatch: toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes), + ProxyPatch: toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes, peer.ProxyMeta.Embedded), }) resp := &proto.SyncResponse{ @@ -104,7 +104,7 @@ func ToComponentSyncResponse( // derive them from. Components purity isn't violated: proxy data isn't // policy-graph-derived, it's externally injected post-Calculate, so the // client merges it on top of its locally-computed NetworkMap. -func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePrefixes bool) *proto.ProxyPatch { +func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePrefixes, localIsProxy bool) *proto.ProxyPatch { if nm == nil { return nil } @@ -114,8 +114,8 @@ func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePr } patch := &proto.ProxyPatch{ - Peers: networkmap.AppendRemotePeerConfig(nil, nm.Peers, dnsName, includeIPv6), - OfflinePeers: networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6), + Peers: networkmap.AppendRemotePeerConfig(nil, nm.Peers, dnsName, includeIPv6, localIsProxy), + OfflinePeers: networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6, localIsProxy), FirewallRules: networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes), Routes: networkmap.ToProtocolRoutes(nm.Routes), RouteFirewallRules: networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules), diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 2b923836c..c30b27f9e 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -160,6 +160,7 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb // 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 +180,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 +190,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/server/peer/peer.go b/management/server/peer/peer.go index 7c4971285..a7be63ff9 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -228,6 +228,7 @@ func (p *Peer) ToComponent() *sharedTypes.ComponentPeer { SupportsIPv6: p.SupportsIPv6(), LoginExpirationEnabled: p.LoginExpirationEnabled, AddedWithSSOLogin: p.AddedWithSSOLogin(), + ProxyEmbedded: p.ProxyMeta.Embedded, } if p.LastLogin != nil { cp.LastLogin = *p.LastLogin diff --git a/shared/management/networkmap/decode.go b/shared/management/networkmap/decode.go index 4864a9dff..07a7e400e 100644 --- a/shared/management/networkmap/decode.go +++ b/shared/management/networkmap/decode.go @@ -275,6 +275,7 @@ func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPee SupportsIPv6: pc.SupportsIpv6, ServerSSHAllowed: pc.ServerSshAllowed, AddedWithSSOLogin: pc.AddedWithSsoLogin, + ProxyEmbedded: pc.ProxyEmbedded, } if pc.LastLoginUnixNano != 0 { peer.LastLogin = time.Unix(0, pc.LastLoginUnixNano) diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go index 7e68861dc..7f7f04204 100644 --- a/shared/management/networkmap/encode.go +++ b/shared/management/networkmap/encode.go @@ -272,8 +272,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 []*types.ComponentPeer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig { for _, rPeer := range peers { allowedIPs := []string{rPeer.IP.String() + "/32"} if includeIPv6 && rPeer.IPv6.IsValid() { @@ -285,11 +286,24 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.Compon 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 *types.ComponentPeer) proto.LazyState { + if localIsProxy || rPeer.ProxyEmbedded { + 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..cd3f862ec 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -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.ProxyEmbedded) 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.ProxyEmbedded) firewallRules := ToProtocolFirewallRules(typedNM.FirewallRules, includeIPv6, useSourcePrefixes) protoNM.FirewallRules = firewallRules diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index a49316e66..7d37df1de 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 @@ -7135,7 +7211,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 +7223,719 @@ 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, - 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, + 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, 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, 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, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, + 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, + 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, + 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, + 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, + 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, - 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, + 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, - 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, - 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x51, - 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, - 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, - 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, - 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, - 0x2e, 0x6d, 0x61, 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, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, + 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, + 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, + 0x28, 0x01, 0x30, 0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, + 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, + 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, + 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, + 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, + 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -7864,242 +7950,244 @@ 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 + 52, // 100: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer + 81, // 101: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry + 39, // 102: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 80, // 103: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList + 82, // 104: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds + 83, // 105: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList + 84, // 106: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet + 74, // 107: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList + 9, // 108: management.ManagementService.Login:input_type -> management.EncryptedMessage + 9, // 109: management.ManagementService.Sync:input_type -> management.EncryptedMessage + 27, // 110: management.ManagementService.GetServerKey:input_type -> management.Empty + 27, // 111: management.ManagementService.isHealthy:input_type -> management.Empty + 9, // 112: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage + 9, // 113: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage + 9, // 114: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage + 9, // 115: management.ManagementService.Logout:input_type -> management.EncryptedMessage + 9, // 116: management.ManagementService.Job:input_type -> management.EncryptedMessage + 9, // 117: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage + 9, // 118: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage + 9, // 119: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage + 9, // 120: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage + 9, // 121: management.ManagementService.Login:output_type -> management.EncryptedMessage + 9, // 122: management.ManagementService.Sync:output_type -> management.EncryptedMessage + 26, // 123: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse + 27, // 124: management.ManagementService.isHealthy:output_type -> management.Empty + 9, // 125: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage + 9, // 126: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage + 27, // 127: management.ManagementService.SyncMeta:output_type -> management.Empty + 27, // 128: management.ManagementService.Logout:output_type -> management.Empty + 9, // 129: management.ManagementService.Job:output_type -> management.EncryptedMessage + 9, // 130: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage + 9, // 131: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage + 9, // 132: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage + 9, // 133: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage + 121, // [121:134] is the sub-list for method output_type + 108, // [108:121] is the sub-list for method input_type + 108, // [108:108] is the sub-list for extension type_name + 108, // [108:108] is the sub-list for extension extendee + 0, // [0:108] is the sub-list for field type_name } func init() { file_management_proto_init() } @@ -9052,7 +9140,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..355fc1ed7 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 diff --git a/shared/management/types/component_types.go b/shared/management/types/component_types.go index a511097b1..41ed758dd 100644 --- a/shared/management/types/component_types.go +++ b/shared/management/types/component_types.go @@ -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. From ed7d4de99904f9f11e3d56effd3dc6fa2dcee30a Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 26 Aug 2026 09:42:50 +0200 Subject: [PATCH 04/40] [client, ios] Migrate switft profile manager to go (#6528) * [client] Add iOS NetBirdSDK profile manager binding Mirror the Android profile manager in the iOS gomobile binding so the core's ID-based profilemanager.ServiceManager owns profile state on iOS too, instead of a parallel Swift reimplementation. Adds client/ios/NetBirdSDK/profile_manager.go (//go:build ios): an ID-based ProfileManager wrapping ServiceManager with iOS-specific path handling (default profile at the container-root netbird.cfg, others as profiles/.json) and a gomobile-friendly API: List/Add/Switch/Rename/ Logout/Remove plus active config/state path accessors. The default profile keeps the reserved "default" id and is never assigned a hex id. * fix(ios): preserve profile name when saving config during auth NewAuth built a fresh in-memory config from only the management URL, so the SSO/setup-key save (DirectWriteOutConfig) overwrote the profile config file the profile manager had just written, wiping the display name to "" and forcing the UI to fall back to the profile ID. Load the existing config when present and override only the management URL, keeping the name and keys. * [client] Extract the mobile profile manager into client/mobile The Android and iOS gomobile bindings carried two near-identical copies of the profile manager. Move the shared implementation into a new client/mobile package and reduce both bindings to thin adapters that only translate to gomobile-friendly types (gomobile binds per package, so the Profile / ProfileArray wrappers have to stay platform-side). Also bring the account-email layer over to the shared package: an SSO login records the account under .account.json so the next login can pass it as an OIDC login_hint. Logout keeps it, profile removal drops it. The suffix deliberately differs from .state.json, which the engine's state manager owns in the same directory on mobile. Adds profilemanager.Prefs (namespaced per-profile preference store) and its cleanup in ServiceManager.RemoveProfile, exposed through the shared manager as ProfilePrefs. --- client/android/login.go | 5 +- client/android/profile_manager.go | 290 ++++------------- client/android/profile_prefs.go | 5 +- client/ios/NetBirdSDK/profile_manager.go | 138 ++++++++ client/mobile/profile_manager.go | 294 ++++++++++++++++++ client/{android => mobile}/profile_state.go | 32 +- .../{android => mobile}/profile_state_test.go | 38 +-- 7 files changed, 532 insertions(+), 270 deletions(-) create mode 100644 client/ios/NetBirdSDK/profile_manager.go create mode 100644 client/mobile/profile_manager.go rename client/{android => mobile}/profile_state.go (69%) rename client/{android => mobile}/profile_state_test.go (73%) 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/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/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) } } From 2621aaa61905d1f55929a2047181e09754c9ccd1 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Wed, 26 Aug 2026 11:48:05 +0200 Subject: [PATCH 05/40] [management, client] add protobuf breaking changes check (#7305) * add protobuf breaking changes check Signed-off-by: Dmitri Dolguikh * disable path check for now Signed-off-by: Dmitri Dolguikh * enable breaking checks Signed-off-by: Dmitri Dolguikh * testing breaking change Signed-off-by: Dmitri Dolguikh * Revert "testing breaking change" This reverts commit 05e6ef9b78fa2baec147191924b7a43e7b7f46f4. Signed-off-by: Dmitri Dolguikh * remove commented out proto paths Signed-off-by: Dmitri Dolguikh * disable pushes Signed-off-by: Dmitri Dolguikh * responded to feedback Signed-off-by: Dmitri Dolguikh * trigger workflow on changes to buf config or the workflow itself Signed-off-by: Dmitri Dolguikh * fix the workflow file name Signed-off-by: Dmitri Dolguikh * explicit config for actions Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- .github/workflows/buf.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/buf.yml 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 From 7e8b4e1417311aed42f51c79b1c4d68968cb11e3 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:43:48 +0900 Subject: [PATCH 06/40] [client, proxy] Remove lazy connection exclusions and run Rosenpass on the embedded proxy (#6763) * Run lazy connection manager for rosenpass peers * Treat forward-target peers as normal lazy connections * Run Rosenpass in permissive mode on the embedded proxy --- client/embed/embed.go | 7 ++ client/internal/conn_mgr.go | 27 +++---- client/internal/conn_mgr_test.go | 2 +- client/internal/engine.go | 79 +++++------------- client/internal/engine_lazy_exclude_test.go | 89 --------------------- proxy/internal/roundtrip/netbird.go | 33 ++++++-- 6 files changed, 63 insertions(+), 174 deletions(-) delete mode 100644 client/internal/engine_lazy_exclude_test.go diff --git a/client/embed/embed.go b/client/embed/embed.go index 079e03c63..5a3d11f24 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -85,6 +85,11 @@ type Options struct { DisableIPv6 bool // BlockInbound blocks all inbound connections from peers BlockInbound bool + // EnableRosenpass enables the Rosenpass post-quantum key exchange. + EnableRosenpass bool + // RosenpassPermissive lets a Rosenpass-enabled peer still connect to peers + // that do not run Rosenpass (falling back to the plain WireGuard PSK). + RosenpassPermissive bool // BlockLANAccess blocks the embedded peer from reaching the host's // LAN (RFC 1918, link-local, loopback) when it's used as a routing // peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful @@ -210,6 +215,8 @@ func New(opts Options) (*Client, error) { DisableIPv6: &opts.DisableIPv6, BlockInbound: &opts.BlockInbound, BlockLANAccess: &opts.BlockLANAccess, + RosenpassEnabled: &opts.EnableRosenpass, + RosenpassPermissive: &opts.RosenpassPermissive, WireguardPort: opts.WireguardPort, MTU: opts.MTU, DNSLabels: parsedLabels, diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 2b9e32130..8b01eabcf 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -39,11 +39,10 @@ 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 @@ -75,11 +74,10 @@ 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 } @@ -87,19 +85,14 @@ func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerSto // 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 is the only condition that disables it. +// 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 } - if e.rosenpassEnabled { - log.Warnf("rosenpass is enabled, lazy connection manager will not be started") - e.statusRecorder.UpdateLazyConnection(false) - return - } - e.initLazyManager(ctx) e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault)) } diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go index 6711c6e54..e3723b5ff 100644 --- a/client/internal/conn_mgr_test.go +++ b/client/internal/conn_mgr_test.go @@ -214,7 +214,7 @@ func TestToExcludedLazyPeers(t *testing.T) { 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(nil, peers) + got := e.toExcludedLazyPeers(peers) if len(got) != len(tt.want) { t.Fatalf("toExcludedLazyPeers() = %v, want %v", got, tt.want) diff --git a/client/internal/engine.go b/client/internal/engine.go index 389418c25..fd2ac1d80 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -833,7 +833,7 @@ func (e *Engine) blockLanAccess() { // modifyPeers updates peers that have been modified (e.g. IP address has been changed). // It closes the existing connection, removes it from the peerConns map, and creates a new one. -func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig, forwardingRules []firewallManager.ForwardRule) error { +func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { // first, check if peers have been modified var modified []*mgmProto.RemotePeerConfig @@ -872,8 +872,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig, forwardin } // third, add the peer connections again for _, p := range modified { - err := e.addNewPeer(p, forwardingRules) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } @@ -1566,8 +1565,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() @@ -1578,14 +1576,14 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.updateOfflinePeers(networkMap.GetOfflinePeers()) done() - remotePeers, err := e.reconcilePeers(networkMap, forwardingRules) + remotePeers, err := e.reconcilePeers(networkMap) if err != nil { return err } // 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") - e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(forwardingRules, remotePeers)) + e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(remotePeers)) done() e.networkSerial = serial @@ -1595,10 +1593,8 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // reconcilePeers applies the remote peer list from the network map (removing, // modifying and adding peers, then updating SSH config) and returns the remote -// peers with our own peer filtered out, for use by later sync steps. The -// forwarding rules are used to decide whether a newly added peer needs an -// always-active connection. -func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap, forwardingRules []firewallManager.ForwardRule) ([]*mgmProto.RemotePeerConfig, error) { +// peers with our own peer filtered out, for use by later sync steps. +func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) { // Filter out own peer from the remote peers list localPubKey := e.config.WgPrivateKey.PublicKey().String() remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers())) @@ -1626,14 +1622,14 @@ func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap, forwardingRules } done = e.phase("modified_peers") - err = e.modifyPeers(remotePeers, forwardingRules) + err = e.modifyPeers(remotePeers) done() if err != nil { return nil, err } done = e.phase("added_peers") - err = e.addNewPeers(remotePeers, forwardingRules) + err = e.addNewPeers(remotePeers) done() if err != nil { return nil, err @@ -1829,10 +1825,9 @@ 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, forwardingRules []firewallManager.ForwardRule) error { +func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { for _, p := range peersUpdate { - err := e.addNewPeer(p, forwardingRules) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } @@ -1840,8 +1835,8 @@ func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig, forwardin } // addNewPeer add peer if connection doesn't exist. A peer that is not lazy by -// policy (or is a forwarder) gets an always-active connection instead. -func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig, forwardingRules []firewallManager.ForwardRule) error { +// policy gets an always-active connection instead. +func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { peerKey := peerConfig.GetWgPubKey() peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps())) if _, ok := e.peerStore.PeerConn(peerKey); ok { @@ -1875,7 +1870,8 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig, forwardingRul log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err) } - if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, e.isPermanentPeer(peerConfig, forwardingRules)); exists { + permanent := !e.connMgr.PeerLazyDefault(peerConfig.GetLazyState()) + if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, permanent); exists { conn.Close(false) return fmt.Errorf("peer already exists: %s", peerKey) } @@ -2668,55 +2664,18 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal } // toExcludedLazyPeers returns the peers that must have an always-active -// connection, so the caller can reconcile the lazy manager's exclude list. -func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { +// 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) for _, p := range peers { - if e.isPermanentPeer(p, rules) { + if !e.connMgr.PeerLazyDefault(p.GetLazyState()) { excludedPeers[p.GetWgPubKey()] = true } } return excludedPeers } -// isPermanentPeer reports whether a peer needs an always-active connection: it -// is not lazy by policy (the per-peer lazy hint or account flag, subject to the -// local override), or it is an ingress forward target. Inbound forwarded traffic -// is initiated remotely and cannot wake a lazy connection, so the peer routing -// the target must stay permanently connected. -func (e *Engine) isPermanentPeer(p *mgmProto.RemotePeerConfig, rules []firewallManager.ForwardRule) bool { - if !e.connMgr.PeerLazyDefault(p.GetLazyState()) { - return true - } - - // Match against the incoming config's AllowedIPs rather than the peer store: - // isPermanentPeer runs in addNewPeer before the peer is in the store, so a - // store lookup would miss a forward target and register it as lazy. - prefixes := make([]netip.Prefix, 0, len(p.GetAllowedIps())) - for _, ipStr := range p.GetAllowedIps() { - if prefix, err := netip.ParsePrefix(ipStr); err == nil { - prefixes = append(prefixes, prefix) - } - } - for _, r := range rules { - if prefixesContain(prefixes, r.TranslatedAddress) { - log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) - return true - } - } - return false -} - -// 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 815db2596..000000000 --- a/client/internal/engine_lazy_exclude_test.go +++ /dev/null @@ -1,89 +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")) - - // Lazy on for normal peers, so the only exclusion under test is the forward target. - e := &Engine{peerStore: store, connMgr: &ConnMgr{force: lazyForceOn}} - - 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) { - // Lazy on for normal peers and no forward rules, so nothing is excluded. - e := &Engine{peerStore: peerstore.NewConnStore(), connMgr: &ConnMgr{force: lazyForceOn}} - - 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/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, From 0a9ce7f7970efe2825f4590936873198530d9c57 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Wed, 26 Aug 2026 12:51:19 +0200 Subject: [PATCH 07/40] [client] fix a flake in TestResolver_ConcurrentStaleHitsCollapseRefresh test (#7326) * fix a flake in TestResolver_ConcurrentStaleHitsCollapseRefresh test Signed-off-by: Dmitri Dolguikh * use testify's eventually asserts Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- client/internal/dns/mgmt/mgmt_refresh_test.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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) From f221347c7a27061dd52fbd0489975bebb95f7e26 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:34:10 +0900 Subject: [PATCH 08/40] [infrastructure] Trigger the dashboard wasm client bump on release tags (#7277) --- .github/workflows/sync-tag.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 }}" }' From 0bd1147ff065c6769e2b9fb9fdfc8019eefa77b1 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:03:40 +0900 Subject: [PATCH 09/40] [client] Keep NetBird traffic out of third-party fwmark rules (#7314) --- client/firewall/nftables/router_linux.go | 2 +- client/iface/configurer/usp.go | 2 +- client/iface/wgproxy/rawsocket/rawsocket.go | 16 +-- .../rawsocket/rawsocket_privileged_test.go | 77 ++++++++++++ client/net/fwmark.go | 110 +++++++++++++++++ client/net/fwmark_test.go | 111 ++++++++++++++++++ client/net/net.go | 35 ------ client/net/net_linux.go | 11 +- 8 files changed, 307 insertions(+), 57 deletions(-) create mode 100644 client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go create mode 100644 client/net/fwmark.go create mode 100644 client/net/fwmark_test.go diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index d3e031c5f..c79f9b8c2 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -763,7 +763,7 @@ func (r *router) addNatRule(pair firewall.RouterPair) error { exprs = append(exprs, sourceExp...) exprs = append(exprs, destExp...) - var markValue uint32 = nbnet.PreroutingFwmarkMasquerade + markValue := nbnet.PreroutingFwmarkMasquerade if pair.Inverse { markValue = nbnet.PreroutingFwmarkMasqueradeReturn } diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 0a25c55bc..2be1b861e 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -502,7 +502,7 @@ func toBytes(s string) (int64, error) { func getFwmark() int { if nbnet.AdvancedRouting() && runtime.GOOS == "linux" { - return nbnet.ControlPlaneMark + return int(nbnet.ControlPlaneMark) } return 0 } diff --git a/client/iface/wgproxy/rawsocket/rawsocket.go b/client/iface/wgproxy/rawsocket/rawsocket.go index bc785b43a..37aaa160f 100644 --- a/client/iface/wgproxy/rawsocket/rawsocket.go +++ b/client/iface/wgproxy/rawsocket/rawsocket.go @@ -10,8 +10,6 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" - - nbnet "github.com/netbirdio/netbird/client/net" ) // PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets @@ -60,14 +58,12 @@ func prepareSenderRawSocket(family int, isIPv4 bool) (net.PacketConn, error) { return nil, fmt.Errorf("binding to lo interface failed: %w", err) } - // Set the fwmark on the socket. - err = nbnet.SetSocketOpt(fd) - if err != nil { - if closeErr := syscall.Close(fd); closeErr != nil { - log.Warnf("failed to close raw socket fd: %v", closeErr) - } - return nil, fmt.Errorf("setting fwmark failed: %w", err) - } + // The socket is bound to lo and only ever sends to the local WireGuard + // instance, a destination the local routing table resolves without help, so + // it carries no fwmark. Staying unmarked also keeps these packets out of + // third-party NAT rules that match on marks: such a rule rewriting the + // source would make WireGuard adopt the rewritten address as the peer + // endpoint. // Convert the file descriptor to a PacketConn. file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd)) diff --git a/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go new file mode 100644 index 000000000..03748c6f9 --- /dev/null +++ b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go @@ -0,0 +1,77 @@ +//go:build linux && !android && privileged + +package rawsocket + +import ( + "net" + "syscall" + "testing" + + "golang.org/x/sys/unix" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// The sender sockets must stay unmarked: a NAT rule matching on fwmark that +// rewrites the source of an injected packet makes WireGuard adopt the rewritten +// address as the peer endpoint. +func TestSenderRawSocketsCarryNoFwmark(t *testing.T) { + // the mark is only ever applied when advanced routing is available, so + // without it the assertion below would hold for the wrong reason + nbnet.Init() + if !nbnet.AdvancedRouting() { + t.Skip("advanced routing unsupported, the sockets carry no mark either way") + } + + tests := []struct { + name string + prepare func() (net.PacketConn, error) + // the proxy treats the IPv6 socket as optional, so a host without IPv6 + // is a reason to skip rather than to fail + optional bool + }{ + {name: "IPv4", prepare: PrepareSenderRawSocketIPv4}, + {name: "IPv6", prepare: PrepareSenderRawSocketIPv6, optional: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + conn, err := tc.prepare() + if err != nil { + if tc.optional { + t.Skipf("prepare raw socket: %v", err) + } + t.Fatalf("prepare raw socket: %v", err) + } + defer func() { + if err := conn.Close(); err != nil { + t.Logf("close raw socket: %v", err) + } + }() + + syscallConn, ok := conn.(syscall.Conn) + if !ok { + t.Fatalf("raw socket %T does not expose a syscall conn", conn) + } + raw, err := syscallConn.SyscallConn() + if err != nil { + t.Fatalf("syscall conn: %v", err) + } + + var mark int + var markErr error + if err := raw.Control(func(fd uintptr) { + mark, markErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK) + }); err != nil { + t.Fatalf("control: %v", err) + } + if markErr != nil { + t.Fatalf("get SO_MARK: %v", markErr) + } + + if mark != 0 { + t.Errorf("SO_MARK = %#x, want 0", mark) + } + }) + } +} diff --git a/client/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)) } From 473392a935f4ad21107fe7d85c1c22c092a1cdda Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:03:56 +0900 Subject: [PATCH 10/40] [client] Tolerate a still-locked updater binary when cleaning up after an update (#7286) --- client/internal/updater/installer/doc.go | 4 ++ .../installer_cleanup_windows_test.go | 67 +++++++++++++++++++ .../updater/installer/installer_common.go | 12 +++- .../installer/installer_common_test.go | 52 ++++++++++++++ .../installer/remove_updater_darwin.go | 12 ++++ .../installer/remove_updater_windows.go | 45 +++++++++++++ .../installer/remove_updater_windows_test.go | 59 ++++++++++++++++ 7 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 client/internal/updater/installer/installer_cleanup_windows_test.go create mode 100644 client/internal/updater/installer/installer_common_test.go create mode 100644 client/internal/updater/installer/remove_updater_darwin.go create mode 100644 client/internal/updater/installer/remove_updater_windows.go create mode 100644 client/internal/updater/installer/remove_updater_windows_test.go 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) + } +} From e06c17cf5921a4a22f3f66f23bc10087ac992ac3 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:28:05 +0200 Subject: [PATCH 11/40] [management] network map from nmap data type (#6919) Signed-off-by: Dmitri Dolguikh Co-authored-by: Dmitri Dolguikh --- .github/workflows/golang-test-linux.yml | 9 +- client/cmd/testutil_test.go | 2 +- client/embed/embed_test.go | 2 +- client/internal/dns_test.go | 87 + client/internal/engine_privileged_test.go | 2 +- client/server/network.go | 1 - client/server/server_privileged_test.go | 2 +- go.mod | 9 +- go.sum | 18 +- .../network_map_db/account_settings_test.go | 58 + .../management/network_map_db/base_data.sql | 53 + .../network_map_db/dns_settings_test.go | 25 + .../management/network_map_db/dns_test.go | 80 + .../management/network_map_db/domain_test.go | 39 + .../management/network_map_db/group_test.go | 54 + .../management/network_map_db/main_test.go | 99 + .../network_map_db/nameserver_test.go | 61 + .../network_map_db/network_map_data.sql | 108 ++ .../network_map_data_golden.json | 546 ++++++ .../network_map_db/network_map_data_test.go | 74 + .../network_map_db/network_resource_test.go | 65 + .../network_map_db/network_router_test.go | 33 + .../management/network_map_db/network_test.go | 56 + .../network_map_db/networks_test.go | 26 + .../management/network_map_db/peer_test.go | 166 ++ .../network_map_db/pg_test_store.go | 121 ++ .../management/network_map_db/policy_test.go | 146 ++ .../management/network_map_db/posture_test.go | 61 + .../management/network_map_db/route_test.go | 87 + .../management/network_map_db/service_test.go | 109 ++ .../network_map_db/sqlite_test_store.go | 48 + .../management/network_map_db/user_test.go | 57 + magefiles/magefile.go | 10 + magefiles/test.go | 74 + .../network_map/controller/controller.go | 443 ++++- .../controller/ipv6_allowed_test.go | 47 + .../network_map/controller/repository.go | 5 + .../network_map/nmaptest/canonicalize.go | 380 ++++ .../network_map/nmaptest/fixture.go | 218 +++ .../network_map/nmaptest/golden_test.go | 12 + .../network_map/nmaptest/legacyaccount.go | 543 ++++++ .../network_map/nmaptest/runner.go | 332 ++++ .../testdata/cases/basic-policy/case.json | 7 + .../cases/basic-policy/golden/peer-a.json | 65 + .../cases/basic-policy/golden/peer-c.json | 102 ++ .../testdata/cases/basic-policy/nmdata.json | 31 + .../testdata/cases/dns-config/case.json | 7 + .../cases/dns-config/golden/peer-a.json | 115 ++ .../cases/dns-config/golden/peer-c.json | 47 + .../testdata/cases/dns-config/nmdata.json | 74 + .../cases/net-domain-resource/case.json | 4 + .../net-domain-resource/golden/peer-a.json | 59 + .../net-domain-resource/golden/peer-r.json | 92 + .../cases/net-domain-resource/nmdata.json | 43 + .../cases/net-host-resource/case.json | 4 + .../net-host-resource/golden/peer-a.json | 56 + .../net-host-resource/golden/peer-r.json | 69 + .../cases/net-host-resource/nmdata.json | 43 + .../cases/net-resource-disabled/case.json | 4 + .../net-resource-disabled/golden/peer-a.json | 34 + .../net-resource-disabled/golden/peer-r.json | 34 + .../cases/net-resource-disabled/nmdata.json | 42 + .../cases/net-resource-no-policy/case.json | 4 + .../net-resource-no-policy/golden/peer-a.json | 34 + .../net-resource-no-policy/golden/peer-r.json | 34 + .../cases/net-resource-no-policy/nmdata.json | 26 + .../net-resource-policy-disabled/case.json | 4 + .../golden/peer-a.json | 34 + .../golden/peer-r.json | 34 + .../net-resource-policy-disabled/nmdata.json | 42 + .../cases/net-router-unvalidated/case.json | 4 + .../net-router-unvalidated/golden/peer-a.json | 34 + .../cases/net-router-unvalidated/nmdata.json | 44 + .../cases/net-routing-peer-group-ha/case.json | 4 + .../golden/peer-a.json | 75 + .../golden/peer-r1.json | 69 + .../golden/peer-r2.json | 69 + .../net-routing-peer-group-ha/nmdata.json | 46 + .../cases/net-subnet-resource/case.json | 4 + .../net-subnet-resource/golden/peer-a.json | 55 + .../net-subnet-resource/golden/peer-b.json | 55 + .../net-subnet-resource/golden/peer-r.json | 76 + .../cases/net-subnet-resource/nmdata.json | 43 + .../cases/policy-peer-to-peer/case.json | 4 + .../policy-peer-to-peer/golden/peer-a.json | 64 + .../policy-peer-to-peer/golden/peer-b.json | 64 + .../policy-peer-to-peer/golden/peer-c.json | 33 + .../cases/policy-peer-to-peer/nmdata.json | 26 + .../cases/policy-ports-and-ranges/case.json | 4 + .../golden/peer-a.json | 81 + .../golden/peer-srv.json | 80 + .../cases/policy-ports-and-ranges/nmdata.json | 74 + .../posture-destination-not-gated/case.json | 4 + .../golden/peer-client.json | 93 + .../golden/peer-srv-old.json | 64 + .../posture-destination-not-gated/nmdata.json | 33 + .../testdata/cases/posture-gated/case.json | 7 + .../cases/posture-gated/golden/peer-b.json | 34 + .../cases/posture-gated/golden/peer-c.json | 65 + .../testdata/cases/posture-gated/nmdata.json | 36 + .../cases/posture-geo-allow/case.json | 4 + .../posture-geo-allow/golden/peer-de.json | 64 + .../golden/peer-nowhere.json | 33 + .../posture-geo-allow/golden/peer-srv.json | 93 + .../posture-geo-allow/golden/peer-us-bos.json | 33 + .../cases/posture-geo-allow/nmdata.json | 46 + .../testdata/cases/posture-geo-deny/case.json | 4 + .../posture-geo-deny/golden/peer-nowhere.json | 33 + .../posture-geo-deny/golden/peer-ru.json | 33 + .../posture-geo-deny/golden/peer-srv.json | 62 + .../cases/posture-geo-deny/nmdata.json | 40 + .../cases/posture-multiple-checks/case.json | 4 + .../golden/peer-badgeo.json | 33 + .../golden/peer-both.json | 64 + .../golden/peer-srv.json | 64 + .../cases/posture-multiple-checks/nmdata.json | 42 + .../cases/posture-network-range/case.json | 4 + .../golden/peer-by-connip.json | 64 + .../golden/peer-office.json | 64 + .../golden/peer-remote.json | 33 + .../golden/peer-srv.json | 93 + .../cases/posture-network-range/nmdata.json | 42 + .../cases/posture-os-version/case.json | 4 + .../golden/peer-lin-ok.json | 64 + .../golden/peer-lin-old.json | 33 + .../posture-os-version/golden/peer-srv.json | 93 + .../posture-os-version/golden/peer-win.json | 33 + .../cases/posture-os-version/nmdata.json | 43 + .../testdata/cases/posture-process/case.json | 4 + .../posture-process/golden/peer-bsd.json | 33 + .../golden/peer-lin-running.json | 62 + .../golden/peer-lin-stopped.json | 33 + .../posture-process/golden/peer-srv.json | 89 + .../cases/posture-process/nmdata.json | 70 + .../cases/posture-resource-policy/case.json | 4 + .../golden/peer-bad.json | 34 + .../golden/peer-ok.json | 56 + .../golden/peer-r.json | 69 + .../cases/posture-resource-policy/nmdata.json | 48 + .../cases/posture-two-policies/case.json | 4 + .../golden/peer-srv-a.json | 33 + .../golden/peer-srv-b.json | 64 + .../posture-two-policies/golden/peer-x.json | 64 + .../cases/posture-two-policies/nmdata.json | 55 + .../proxy-service-domain-resource/case.json | 7 + .../golden/proxy-peer.json | 60 + .../golden/router-peer.json | 80 + .../proxy-service-domain-resource/nmdata.json | 44 + .../cases/proxy-service-peer-target/case.json | 7 + .../golden/app-peer.json | 64 + .../golden/proxy-peer.json | 65 + .../proxy-service-peer-target/nmdata.json | 29 + .../proxy-service-private-access/case.json | 7 + .../golden/proxy-peer.json | 75 + .../golden/user-peer.json | 77 + .../proxy-service-private-access/nmdata.json | 26 + .../route-access-control-groups/case.json | 4 + .../golden/peer-a.json | 76 + .../golden/peer-r.json | 119 ++ .../route-access-control-groups/nmdata.json | 45 + .../cases/route-peer-groups-ha/case.json | 4 + .../route-peer-groups-ha/golden/peer-a.json | 114 ++ .../route-peer-groups-ha/golden/peer-r1.json | 93 + .../cases/route-peer-groups-ha/nmdata.json | 43 + .../testdata/cases/routes-resources/case.json | 7 + .../cases/routes-resources/golden/peer-a.json | 86 + .../cases/routes-resources/golden/peer-r.json | 138 ++ .../cases/routes-resources/nmdata.json | 97 + .../cases/ssh-authorized-groups/case.json | 4 + .../ssh-authorized-groups/golden/peer-a.json | 63 + .../golden/peer-srv.json | 109 ++ .../cases/ssh-authorized-groups/nmdata.json | 37 + .../cases/ssh-authorized-user/case.json | 4 + .../ssh-authorized-user/golden/peer-srv.json | 74 + .../cases/ssh-authorized-user/nmdata.json | 29 + .../ssh-fallback-allowed-users/case.json | 4 + .../golden/peer-srv.json | 76 + .../ssh-fallback-allowed-users/nmdata.json | 29 + .../cases/ssh-legacy-disabled/case.json | 4 + .../ssh-legacy-disabled/golden/peer-srv.json | 64 + .../cases/ssh-legacy-disabled/nmdata.json | 30 + .../network_map_db/factory/db_store.go | 76 + .../network_map_db/network_map_data.go | 277 +++ .../network_map_db/network_map_data_test.go | 399 ++++ .../network_map_db/pgsql/account_settings.go | 61 + .../internals/network_map_db/pgsql/dns.go | 33 + .../network_map_db/pgsql/dns_settings.go | 45 + .../internals/network_map_db/pgsql/domain.go | 25 + .../internals/network_map_db/pgsql/group.go | 64 + .../network_map_db/pgsql/nameserver.go | 31 + .../internals/network_map_db/pgsql/network.go | 39 + .../network_map_db/pgsql/network_resource.go | 31 + .../network_map_db/pgsql/network_router.go | 80 + .../network_map_db/pgsql/networks.go | 36 + .../internals/network_map_db/pgsql/peer.go | 34 + .../network_map_db/pgsql/pg_store.go | 128 ++ .../internals/network_map_db/pgsql/policy.go | 34 + .../internals/network_map_db/pgsql/posture.go | 44 + .../internals/network_map_db/pgsql/route.go | 33 + .../internals/network_map_db/pgsql/service.go | 51 + .../internals/network_map_db/pgsql/user.go | 60 + .../internals/network_map_db/shared_types.go | 472 +++++ .../network_map_db/shared_types_test.go | 38 + .../sql_type_conversion_test.go | 253 +++ .../network_map_db/sqlite/account_setting.go | 47 + .../internals/network_map_db/sqlite/dns.go | 32 + .../network_map_db/sqlite/dns_setting.go | 42 + .../internals/network_map_db/sqlite/domain.go | 24 + .../internals/network_map_db/sqlite/group.go | 70 + .../network_map_db/sqlite/nameserver.go | 30 + .../network_map_db/sqlite/network.go | 38 + .../network_map_db/sqlite/network_resource.go | 30 + .../network_map_db/sqlite/network_router.go | 74 + .../network_map_db/sqlite/networks.go | 35 + .../internals/network_map_db/sqlite/peer.go | 33 + .../internals/network_map_db/sqlite/policy.go | 33 + .../network_map_db/sqlite/posture.go | 43 + .../internals/network_map_db/sqlite/route.go | 32 + .../network_map_db/sqlite/service.go | 89 + .../network_map_db/sqlite/sqlite_store.go | 148 ++ .../internals/network_map_db/sqlite/user.go | 84 + .../network_map_db/struct_helpers.go | 157 ++ management/internals/server/boot.go | 23 + management/internals/server/controllers.go | 2 +- .../shared/grpc/components_encoder.go | 154 +- .../shared/grpc/components_encoder_test.go | 178 +- .../grpc/components_envelope_response.go | 18 +- .../grpc/components_envelope_response_test.go | 85 +- .../internals/shared/grpc/conversion.go | 8 +- .../internals/shared/grpc/conversion_test.go | 6 +- management/internals/shared/grpc/server.go | 8 +- .../internals/shared/requestbuffer/buffer.go | 102 ++ .../shared/requestbuffer/buffer_test.go | 106 ++ management/server/account_request_buffer.go | 111 +- management/server/account_test.go | 2 +- .../server/affected_peers_property_test.go | 2 - management/server/dns_test.go | 2 +- management/server/groups/manager.go | 9 +- .../http/handlers/peers/peers_handler.go | 26 +- .../testing/testing_tools/channel/channel.go | 4 +- management/server/identity_provider_test.go | 2 +- management/server/integrated_validator.go | 5 +- .../integrated_validator_mock.go | 187 ++ .../integrated_validator/interface.go | 9 +- .../validator/validator.go | 3 +- management/server/management_proto_test.go | 2 +- management/server/management_test.go | 2 +- management/server/nameserver_test.go | 2 +- .../networks/resources/types/resource.go | 22 - .../server/networks/routers/types/router.go | 31 - management/server/peer.go | 3 +- management/server/peer/peer.go | 38 +- management/server/peer_test.go | 19 +- management/server/route_test.go | 4 +- management/server/store/sql_store.go | 4 +- .../store/sql_store_get_account_test.go | 2 +- management/server/store/store.go | 8 +- management/server/types/account.go | 409 +---- management/server/types/account_components.go | 730 +------- .../server/types/account_components_test.go | 7 +- .../server/types/account_networkmapdata.go | 613 +++++++ .../types/account_private_netmap_test.go | 4 +- management/server/types/account_test.go | 596 +----- management/server/types/aliases.go | 92 +- .../server}/types/dns_settings.go | 0 management/server/types/group.go | 38 +- management/server/types/ipv6_endtoend_test.go | 4 +- .../types/legacynmap/account_components.go | 701 +++++++ management/server/types/legacynmap/aliases.go | 35 + .../server/types/legacynmap/benchmark_test.go | 350 ++++ .../types/legacynmap}/component_types.go | 2 +- .../server/types/legacynmap/converters.go | 127 ++ .../server/types/legacynmap/copied_funcs.go | 282 +++ management/server/types/legacynmap/doc.go | 16 + .../types/legacynmap/equivalence_test.go | 680 +++++++ .../types/legacynmap/firewall_helpers.go | 155 ++ .../types/legacynmap/networkmap_components.go | 1032 +++++++++++ .../server/types/legacynmap/proto_legacy.go | 220 +++ .../server/types/legacynmap/proxy_policies.go | 150 ++ management/server/types/network.go | 271 +++ management/server/types/network_test.go | 264 +++ .../networkmap_components_correctness_test.go | 6 +- .../types/networkmap_components_test.go | 5 +- .../types/networkmap_wire_benchmark_test.go | 6 +- .../types/networkmap_wire_breakdown_test.go | 2 +- .../server}/types/policy.go | 109 -- management/server/types/policyrule.go | 196 ++ management/server/types/resource.go | 30 + management/server/types/user.go | 2 +- management/server/user_test.go | 2 +- shared/management/client/client_test.go | 2 +- .../integration_reference.go | 0 shared/management/networkmap/decode.go | 233 ++- shared/management/networkmap/decode_test.go | 61 + shared/management/networkmap/encode.go | 17 +- shared/management/networkmap/envelope.go | 10 +- shared/management/networkmap/envelope_test.go | 97 +- .../networkmap/networkmapcompute.go | 812 +++++++++ .../networkmap/networkmapcompute_test.go | 1610 +++++++++++++++++ .../management/networkmap/networkmapdata.go | 79 + .../networkmap/nmdata/account_settings.go | 18 + shared/management/networkmap/nmdata/dns.go | 18 + .../networkmap/nmdata/dns_settings.go | 6 + shared/management/networkmap/nmdata/group.go | 30 + .../networkmap/nmdata/group_test.go | 84 + .../networkmap/nmdata/nameserver.go | 24 + .../management/networkmap/nmdata/network.go | 16 + .../networkmap/nmdata/network_resource.go | 18 + .../networkmap/nmdata/network_router.go | 10 + shared/management/networkmap/nmdata/peer.go | 129 ++ shared/management/networkmap/nmdata/policy.go | 96 + .../management/networkmap/nmdata/posture.go | 67 + .../networkmap/nmdata/posture_geo_location.go | 45 + .../networkmap/nmdata/posture_nb_version.go | 38 + .../networkmap/nmdata/posture_network.go | 62 + .../networkmap/nmdata/posture_os_version.go | 79 + .../networkmap/nmdata/posture_process.go | 56 + shared/management/networkmap/nmdata/route.go | 108 ++ .../management/networkmap/nmdata/service.go | 25 + .../networkmap/peers_custom_zone.go | 111 ++ shared/management/networkmap/proxypolicies.go | 209 +++ shared/management/proto/management.pb.go | 500 ++--- shared/management/proto/management.proto | 7 +- shared/management/types/firewall_helpers.go | 35 +- shared/management/types/firewall_rule.go | 21 +- shared/management/types/firewall_rule_test.go | 68 +- shared/management/types/network.go | 308 +--- shared/management/types/network_merge_test.go | 41 - shared/management/types/network_test.go | 271 +-- .../management/types/networkmap_components.go | 229 +-- .../types/networkmap_components_compact.go | 48 +- shared/management/types/nmdata_convert.go | 70 + shared/management/types/policyrule.go | 264 +-- shared/management/types/resource.go | 32 +- version/compare.go | 31 + version/compare_test.go | 72 + version/version.go | 24 - version/version_test.go | 71 +- 338 files changed, 24867 insertions(+), 3799 deletions(-) create mode 100644 integration_tests/management/network_map_db/account_settings_test.go create mode 100644 integration_tests/management/network_map_db/base_data.sql create mode 100644 integration_tests/management/network_map_db/dns_settings_test.go create mode 100644 integration_tests/management/network_map_db/dns_test.go create mode 100644 integration_tests/management/network_map_db/domain_test.go create mode 100644 integration_tests/management/network_map_db/group_test.go create mode 100644 integration_tests/management/network_map_db/main_test.go create mode 100644 integration_tests/management/network_map_db/nameserver_test.go create mode 100644 integration_tests/management/network_map_db/network_map_data.sql create mode 100644 integration_tests/management/network_map_db/network_map_data_golden.json create mode 100644 integration_tests/management/network_map_db/network_map_data_test.go create mode 100644 integration_tests/management/network_map_db/network_resource_test.go create mode 100644 integration_tests/management/network_map_db/network_router_test.go create mode 100644 integration_tests/management/network_map_db/network_test.go create mode 100644 integration_tests/management/network_map_db/networks_test.go create mode 100644 integration_tests/management/network_map_db/peer_test.go create mode 100644 integration_tests/management/network_map_db/pg_test_store.go create mode 100644 integration_tests/management/network_map_db/policy_test.go create mode 100644 integration_tests/management/network_map_db/posture_test.go create mode 100644 integration_tests/management/network_map_db/route_test.go create mode 100644 integration_tests/management/network_map_db/service_test.go create mode 100644 integration_tests/management/network_map_db/sqlite_test_store.go create mode 100644 integration_tests/management/network_map_db/user_test.go create mode 100644 magefiles/magefile.go create mode 100644 magefiles/test.go create mode 100644 management/internals/controllers/network_map/controller/ipv6_allowed_test.go create mode 100644 management/internals/controllers/network_map/nmaptest/canonicalize.go create mode 100644 management/internals/controllers/network_map/nmaptest/fixture.go create mode 100644 management/internals/controllers/network_map/nmaptest/golden_test.go create mode 100644 management/internals/controllers/network_map/nmaptest/legacyaccount.go create mode 100644 management/internals/controllers/network_map/nmaptest/runner.go create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json create mode 100644 management/internals/network_map_db/factory/db_store.go create mode 100644 management/internals/network_map_db/network_map_data.go create mode 100644 management/internals/network_map_db/network_map_data_test.go create mode 100644 management/internals/network_map_db/pgsql/account_settings.go create mode 100644 management/internals/network_map_db/pgsql/dns.go create mode 100644 management/internals/network_map_db/pgsql/dns_settings.go create mode 100644 management/internals/network_map_db/pgsql/domain.go create mode 100644 management/internals/network_map_db/pgsql/group.go create mode 100644 management/internals/network_map_db/pgsql/nameserver.go create mode 100644 management/internals/network_map_db/pgsql/network.go create mode 100644 management/internals/network_map_db/pgsql/network_resource.go create mode 100644 management/internals/network_map_db/pgsql/network_router.go create mode 100644 management/internals/network_map_db/pgsql/networks.go create mode 100644 management/internals/network_map_db/pgsql/peer.go create mode 100644 management/internals/network_map_db/pgsql/pg_store.go create mode 100644 management/internals/network_map_db/pgsql/policy.go create mode 100644 management/internals/network_map_db/pgsql/posture.go create mode 100644 management/internals/network_map_db/pgsql/route.go create mode 100644 management/internals/network_map_db/pgsql/service.go create mode 100644 management/internals/network_map_db/pgsql/user.go create mode 100644 management/internals/network_map_db/shared_types.go create mode 100644 management/internals/network_map_db/shared_types_test.go create mode 100644 management/internals/network_map_db/sql_type_conversion_test.go create mode 100644 management/internals/network_map_db/sqlite/account_setting.go create mode 100644 management/internals/network_map_db/sqlite/dns.go create mode 100644 management/internals/network_map_db/sqlite/dns_setting.go create mode 100644 management/internals/network_map_db/sqlite/domain.go create mode 100644 management/internals/network_map_db/sqlite/group.go create mode 100644 management/internals/network_map_db/sqlite/nameserver.go create mode 100644 management/internals/network_map_db/sqlite/network.go create mode 100644 management/internals/network_map_db/sqlite/network_resource.go create mode 100644 management/internals/network_map_db/sqlite/network_router.go create mode 100644 management/internals/network_map_db/sqlite/networks.go create mode 100644 management/internals/network_map_db/sqlite/peer.go create mode 100644 management/internals/network_map_db/sqlite/policy.go create mode 100644 management/internals/network_map_db/sqlite/posture.go create mode 100644 management/internals/network_map_db/sqlite/route.go create mode 100644 management/internals/network_map_db/sqlite/service.go create mode 100644 management/internals/network_map_db/sqlite/sqlite_store.go create mode 100644 management/internals/network_map_db/sqlite/user.go create mode 100644 management/internals/network_map_db/struct_helpers.go create mode 100644 management/internals/shared/requestbuffer/buffer.go create mode 100644 management/internals/shared/requestbuffer/buffer_test.go create mode 100644 management/server/integrations/integrated_validator/integrated_validator_mock.go create mode 100644 management/server/types/account_networkmapdata.go rename {shared/management => management/server}/types/dns_settings.go (100%) create mode 100644 management/server/types/legacynmap/account_components.go create mode 100644 management/server/types/legacynmap/aliases.go create mode 100644 management/server/types/legacynmap/benchmark_test.go rename {shared/management/types => management/server/types/legacynmap}/component_types.go (99%) create mode 100644 management/server/types/legacynmap/converters.go create mode 100644 management/server/types/legacynmap/copied_funcs.go create mode 100644 management/server/types/legacynmap/doc.go create mode 100644 management/server/types/legacynmap/equivalence_test.go create mode 100644 management/server/types/legacynmap/firewall_helpers.go create mode 100644 management/server/types/legacynmap/networkmap_components.go create mode 100644 management/server/types/legacynmap/proto_legacy.go create mode 100644 management/server/types/legacynmap/proxy_policies.go create mode 100644 management/server/types/network.go create mode 100644 management/server/types/network_test.go rename {shared/management => management/server}/types/policy.go (58%) create mode 100644 management/server/types/policyrule.go create mode 100644 management/server/types/resource.go rename {management/server => shared/management}/integration_reference/integration_reference.go (100%) create mode 100644 shared/management/networkmap/decode_test.go create mode 100644 shared/management/networkmap/networkmapcompute.go create mode 100644 shared/management/networkmap/networkmapcompute_test.go create mode 100644 shared/management/networkmap/networkmapdata.go create mode 100644 shared/management/networkmap/nmdata/account_settings.go create mode 100644 shared/management/networkmap/nmdata/dns.go create mode 100644 shared/management/networkmap/nmdata/dns_settings.go create mode 100644 shared/management/networkmap/nmdata/group.go create mode 100644 shared/management/networkmap/nmdata/group_test.go create mode 100644 shared/management/networkmap/nmdata/nameserver.go create mode 100644 shared/management/networkmap/nmdata/network.go create mode 100644 shared/management/networkmap/nmdata/network_resource.go create mode 100644 shared/management/networkmap/nmdata/network_router.go create mode 100644 shared/management/networkmap/nmdata/peer.go create mode 100644 shared/management/networkmap/nmdata/policy.go create mode 100644 shared/management/networkmap/nmdata/posture.go create mode 100644 shared/management/networkmap/nmdata/posture_geo_location.go create mode 100644 shared/management/networkmap/nmdata/posture_nb_version.go create mode 100644 shared/management/networkmap/nmdata/posture_network.go create mode 100644 shared/management/networkmap/nmdata/posture_os_version.go create mode 100644 shared/management/networkmap/nmdata/posture_process.go create mode 100644 shared/management/networkmap/nmdata/route.go create mode 100644 shared/management/networkmap/nmdata/service.go create mode 100644 shared/management/networkmap/peers_custom_zone.go create mode 100644 shared/management/networkmap/proxypolicies.go delete mode 100644 shared/management/types/network_merge_test.go create mode 100644 shared/management/types/nmdata_convert.go create mode 100644 version/compare.go create mode 100644 version/compare_test.go diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 004b78b3e..c93e36e4e 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -730,6 +730,11 @@ jobs: - name: Install modules run: go mod tidy + - name: Run Mage + uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0 + with: + install-only: true + - name: check git status run: git --no-pager diff --exit-code @@ -738,9 +743,7 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - go test -tags=integration -coverprofile=coverage.txt \ - -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \ - -timeout 20m ./management/server/http/... + mage integrationtest:all -gotestflags="-coverprofile=coverage.txt" - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index f40056f83..328a15454 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil) accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go index 27beb8934..4ff5c9978 100644 --- a/client/embed/embed_test.go +++ b/client/embed/embed_test.go @@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) - networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg) + networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil) accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) require.NoError(t, err) diff --git a/client/internal/dns_test.go b/client/internal/dns_test.go index e15cc8fb7..031431efe 100644 --- a/client/internal/dns_test.go +++ b/client/internal/dns_test.go @@ -8,7 +8,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/client/iface/wgaddr" nbdns "github.com/netbirdio/netbird/dns" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) func TestCreatePTRRecord_IPv4(t *testing.T) { @@ -136,3 +138,88 @@ func TestAddReverseZone_IPv6(t *testing.T) { assert.Len(t, reverseZone.Records, 1) assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type) } + +// TestToDNSConfig_ZoneFlagsPreserved pins the per-zone NonAuthoritative flag +// through the legacy DNSConfig path. A non-authoritative zone is match-only: +// the local resolver falls through to the upstream for an in-zone name it does +// not define. The built-in peer zone is the authoritative one and must stay +// that way, so the flag has to travel per zone rather than be derived. +func TestToDNSConfig_ZoneFlagsPreserved(t *testing.T) { + config := toDNSConfig(&mgmProto.DNSConfig{ + ServiceEnable: true, + CustomZones: []*mgmProto.CustomZone{ + { + Domain: "netbird.cloud.", + Records: []*mgmProto.SimpleRecord{ + {Name: "peer1.netbird.cloud.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.1"}, + }, + }, + { + Domain: "corp.internal.", + NonAuthoritative: true, + SearchDomainDisabled: true, + Records: []*mgmProto.SimpleRecord{ + {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"}, + }, + }, + }, + }, wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }) + + zones := make(map[string]nbdns.CustomZone, len(config.CustomZones)) + for _, zone := range config.CustomZones { + zones[zone.Domain] = zone + } + + peerZone, ok := zones["netbird.cloud."] + require.True(t, ok, "peer zone must survive") + assert.False(t, peerZone.NonAuthoritative, "the built-in peer zone owns the account domain and stays authoritative") + + accountZone, ok := zones["corp.internal."] + require.True(t, ok, "account zone must survive") + assert.True(t, accountZone.NonAuthoritative, "an account zone stays match-only, else undefined in-zone names get black-holed") + assert.True(t, accountZone.SearchDomainDisabled) +} + +// TestToDNSConfig_SingleZoneForcedAuthoritative pins the compatibility clause +// in toDNSConfig: a config carrying exactly one zone is treated as +// authoritative no matter what the server said, because servers that predate +// the NonAuthoritative field send only the peer FQDN zone. +// +// The clause can only ever downgrade an explicit true to false, so a server +// that legitimately sends a single non-authoritative zone — an account whose +// only zone is a custom one, with no peer records to build the built-in zone +// from — gets that zone's whole apex black-holed on the client. Real accounts +// always carry the peer zone alongside, which is why this is latent. Narrowing +// it needs a way to tell "unset" from "false" on the wire, or the account +// domain passed down here; until then this test states the contract so a +// change to it is deliberate. +func TestToDNSConfig_SingleZoneForcedAuthoritative(t *testing.T) { + config := toDNSConfig(&mgmProto.DNSConfig{ + ServiceEnable: true, + CustomZones: []*mgmProto.CustomZone{ + { + Domain: "corp.internal.", + NonAuthoritative: true, + Records: []*mgmProto.SimpleRecord{ + {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"}, + }, + }, + }, + }, wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }) + + require.NotEmpty(t, config.CustomZones) + assert.Equal(t, "corp.internal.", config.CustomZones[0].Domain) + assert.False(t, config.CustomZones[0].NonAuthoritative, + "a lone zone is forced authoritative for pre-NonAuthoritative servers") + + // The reverse zone the config gains afterwards must not feed back into the + // decision: the compat gate counts the zones the server sent. + require.Len(t, config.CustomZones, 2, "a reverse zone is appended for the overlay prefix") + assert.Equal(t, "64.100.in-addr.arpa.", config.CustomZones[1].Domain) +} diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index 032992464..1428b742c 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -519,7 +519,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, "", err diff --git a/client/server/network.go b/client/server/network.go index c390b8180..69eaabf8a 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -232,4 +232,3 @@ func toNetIDs(routes []string) []route.NetID { } return netIDs } - diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go index 0366ccb31..aa6e99026 100644 --- a/client/server/server_privileged_test.go +++ b/client/server/server_privileged_test.go @@ -200,7 +200,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { return nil, "", err diff --git a/go.mod b/go.mod index efec8c94d..09c3df95b 100644 --- a/go.mod +++ b/go.mod @@ -71,17 +71,18 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.7.0 - github.com/jackc/pgx/v5 v5.5.5 + github.com/jackc/pgx/v5 v5.10.0 github.com/libdns/route53 v1.5.0 github.com/libp2p/go-netroute v0.4.0 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 + github.com/magefile/mage v1.17.2 github.com/mdlayher/socket v0.5.1 github.com/mdp/qrterminal/v3 v3.2.1 github.com/miekg/dns v1.1.72 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/moby/moby/api v1.54.1 github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 - github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 + github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 @@ -236,8 +237,8 @@ require ( github.com/huin/goupnp v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index da68b6458..e5bf6248d 100644 --- a/go.sum +++ b/go.sum @@ -341,12 +341,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= -github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -413,6 +413,8 @@ github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= +github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= @@ -482,8 +484,8 @@ github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVU github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 h1:iJeUvSMC0BTpkw7u4JyWcY4/3dl7fEL9DR/TpKf2+1w= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87/go.mod h1:pmsCPx1S0nuZRxCextGpc9AV4hLgGSuTsc4NMuwGeCo= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= diff --git a/integration_tests/management/network_map_db/account_settings_test.go b/integration_tests/management/network_map_db/account_settings_test.go new file mode 100644 index 000000000..d7927aaf1 --- /dev/null +++ b/integration_tests/management/network_map_db/account_settings_test.go @@ -0,0 +1,58 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + "time" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetAccountSettings(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into accounts (id, settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) + values('account-3',null,null,null,null,null,null,null,null,null,null,null)`) + + accountSettings, err := conn(t, ctx).GetAccountSettings(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400000000000 * time.Nanosecond, + PeerInactivityExpirationEnabled: false, + PeerInactivityExpiration: 86400000000000 * time.Nanosecond, + DNSDomain: "", + IPv6EnabledGroups: []string{"group-one-resource-id"}, + RoutingPeerDNSResolutionEnabled: false, + LazyConnectionEnabled: false, + AutoUpdateVersion: "disabled", + AutoUpdateAlways: false, + MetricsPushEnabled: false, + }) + + accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400000000000 * time.Nanosecond, + PeerInactivityExpirationEnabled: false, + PeerInactivityExpiration: 86400000000000 * time.Nanosecond, + DNSDomain: "", + IPv6EnabledGroups: []string{"group-two-resources-id"}, + RoutingPeerDNSResolutionEnabled: false, + LazyConnectionEnabled: false, + AutoUpdateVersion: "disabled", + AutoUpdateAlways: false, + MetricsPushEnabled: false, + }) + + accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-3") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{}) +} diff --git a/integration_tests/management/network_map_db/base_data.sql b/integration_tests/management/network_map_db/base_data.sql new file mode 100644 index 000000000..136df00ac --- /dev/null +++ b/integration_tests/management/network_map_db/base_data.sql @@ -0,0 +1,53 @@ +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-1','network-1','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]', + true, 86400000000000, false, + 86400000000000, null, '["group-one-resource-id"]', false, + false, 'disabled', false, false); +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-2','network-2','{"IP":"110.0.0.0","Mask":"//8AAA=="}','{"IP":"fddf:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',2,null, + true, 86400000000000, false, + 86400000000000, null, '["group-two-resources-id"]', false, + false, 'disabled', false, false); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-one-resource-id','account-1','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-two-resources-id','account-1','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','group-two-resources-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-no-resources-id','account-1','group-3-name', null,'group-no-resources-id-public'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-1','group-one-resource-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-2','group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-3','group-two-resources-id'); +insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-1','account-1','key-1','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-1.netbird.services', + '0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.148.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-2','account-1','key-2','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-2.netbird.services', + '0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0, + 'DE','Berlin','"46.201.149.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-3','account-1','key-3','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-3.netbird.services', + '0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.150.187"'); + diff --git a/integration_tests/management/network_map_db/dns_settings_test.go b/integration_tests/management/network_map_db/dns_settings_test.go new file mode 100644 index 000000000..95ac84aed --- /dev/null +++ b/integration_tests/management/network_map_db/dns_settings_test.go @@ -0,0 +1,25 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetDnsSettings(t *testing.T) { + ctx := context.TODO() + + settings, err := conn(t, ctx).GetDnsSettings(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, settings, nmdata.DNSSettings{ + DisabledManagementGroups: []string{"disabled-group-1", "disabled-group-2"}, + }) + + settings, err = conn(t, ctx).GetDnsSettings(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, settings, nmdata.DNSSettings{}) +} diff --git a/integration_tests/management/network_map_db/dns_test.go b/integration_tests/management/network_map_db/dns_test.go new file mode 100644 index 000000000..33023061d --- /dev/null +++ b/integration_tests/management/network_map_db/dns_test.go @@ -0,0 +1,80 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/miekg/dns" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetAppliedZoneCandidatesViaPgxConnection(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-1','account-1','test-1.com',true,true,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-2','account-1','test-2.com',true,false,'["group-two-resources-id"]')`) + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-3','account-1','test-3.com',false,true,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-1','account-1','zone-1','test.test-1.com','A',1800,'1.1.1.1')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-2','account-1','zone-1','test2.test-1.com','A',1800,'1.1.1.2')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-3','account-1','zone-1','test3.test-1.com','CNAME',1800,'test4.test-1.com')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-4','account-1','zone-2','test2.test-2.com','CNAME',1800,'test3.test-2.com')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-5','account-1','zone-3','test.test-3.com','A',1800,'1.1.1.3')`) + + zoneCandidates, err := conn(t, ctx).GetAppliedZoneCandidates(ctx, "account-1") + assert.NoError(t, err) + + // Zone domains and record names are fully qualified, and the zone is served + // non-authoritatively — the account-side builder + // (types.buildAppliedZoneCandidates) states the same shape, and both feed the + // one client-facing map, so the two have to agree. + assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{ + DistributionGroups: []string{"group-one-resource-id"}, + Zone: nmdata.CustomZone{ + Domain: "test-1.com.", + SearchDomainDisabled: false, + NonAuthoritative: true, + Records: []nmdata.SimpleRecord{ + {Name: "test.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.1"}, + {Name: "test2.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.2"}, + {Name: "test3.test-1.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test4.test-1.com."}, + }, + }, + }) + assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{ + DistributionGroups: []string{"group-two-resources-id"}, + Zone: nmdata.CustomZone{ + Domain: "test-2.com.", + SearchDomainDisabled: true, + NonAuthoritative: true, + Records: []nmdata.SimpleRecord{ + {Name: "test2.test-2.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test3.test-2.com."}, + }, + }, + }) + + // A zone an admin switched off reaches no peer. + for _, candidate := range zoneCandidates { + assert.NotEqual(t, "test-3.com.", candidate.Zone.Domain, "disabled zone must not be a candidate") + assert.NotEqual(t, "test-3.com", candidate.Zone.Domain, "disabled zone must not be a candidate") + } +} diff --git a/integration_tests/management/network_map_db/domain_test.go b/integration_tests/management/network_map_db/domain_test.go new file mode 100644 index 000000000..8434a76c3 --- /dev/null +++ b/integration_tests/management/network_map_db/domain_test.go @@ -0,0 +1,39 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "database/sql" + "testing" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/stretchr/testify/assert" +) + +func TestGetDomains(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-1','account-1','test-1.com','target-1.cluster.local')`) + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-2','account-1','test-2.com','target-2.cluster.local')`) + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-3','account-1',null,null)`) + + domains, err := conn(t, ctx).GetDomains(ctx, "account-1") + assert.NoError(t, err) + assert.Len(t, domains, 2) + + assert.Contains(t, domains, networkmapdb.Domain{ + Domain: sql.NullString{String: "test-1.com", Valid: true}, + TargetCluster: sql.NullString{String: "target-1.cluster.local", Valid: true}, + }) + assert.Contains(t, domains, networkmapdb.Domain{ + Domain: sql.NullString{String: "test-2.com", Valid: true}, + TargetCluster: sql.NullString{String: "target-2.cluster.local", Valid: true}, + }) +} diff --git a/integration_tests/management/network_map_db/group_test.go b/integration_tests/management/network_map_db/group_test.go new file mode 100644 index 000000000..3ccf96eb0 --- /dev/null +++ b/integration_tests/management/network_map_db/group_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetGroups(t *testing.T) { + ctx := context.TODO() + + groups, resourceToGroupIdx, err := conn(t, ctx).GetGroups(ctx, "account-1") + assert.NoError(t, err) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-one-resource-id", Name: "group-1-name", PublicID: "group-one-resource-id-public", Resources: []nmdata.Resource{{ID: "host-id-1", Type: "host"}}, Peers: []string{"peer-id-1"}}, + ) + assert.NotNil(t, resourceToGroupIdx["host-id-1"]["group-one-resource-id"]) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-two-resources-id", Name: "group-2-name", PublicID: "group-two-resources-id-public", + Resources: []nmdata.Resource{{ID: "subnet-id-1", Type: "subnet"}, {ID: "host-id-2", Type: "host"}}, + Peers: []string{"peer-id-2", "peer-id-3"}}, + ) + assert.NotNil(t, resourceToGroupIdx["host-id-2"]["group-two-resources-id"]) + assert.NotNil(t, resourceToGroupIdx["subnet-id-1"]["group-two-resources-id"]) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-no-resources-id", Name: "group-3-name", PublicID: "group-no-resources-id-public"}) +} + +// Verify handling of empty fields in groups table +// Verify that group's PublicID gets populated on retrieval +// TODO (dmitri) PublicID should not be populated with delta updates, +// which require stable PublicIDs +func TestGetGroupsWithoutExpectedFields(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + "insert into accounts (id) VALUES('random-id')") + + execQuery(t, ctx, + "insert into groups (id, account_id) VALUES('g2-test-group-id-1','random-id')") + + groups, _, err := conn(t, ctx).GetGroups(ctx, "random-id") + assert.NoError(t, err) + require.Len(t, groups, 1) + assert.NotEmpty(t, groups[0].PublicID) +} diff --git a/integration_tests/management/network_map_db/main_test.go b/integration_tests/management/network_map_db/main_test.go new file mode 100644 index 000000000..78c8c8ec8 --- /dev/null +++ b/integration_tests/management/network_map_db/main_test.go @@ -0,0 +1,99 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + _ "embed" + "os" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" + "github.com/netbirdio/netbird/management/server/types" +) + +//go:embed base_data.sql +var baseData string + +var ( + pgstore *networkmap_pgsql.PgStore + sqlitestore *networkmap_sqlite.SqliteStore + engine string +) + +func TestMain(m *testing.M) { + var cleanup func() + kind, _ := os.LookupEnv("NETBIRD_STORE_ENGINE") + switch kind { + case string(types.PostgresStoreEngine): + engine = string(types.PostgresStoreEngine) + pgstore, cleanup = createPGTestStore(baseData) + pgstore.UsingTimeZone(time.UTC) + case "", string(types.SqliteStoreEngine): + engine = string(types.SqliteStoreEngine) + sqlitestore, cleanup = createSqliteTestStore(baseData) + default: + log.Fatalf("unsupported db '%s' in NETBIRD_STORE_ENGINE env var", kind) + } + + code := m.Run() + + cleanup() + os.Exit(code) +} + +func conn(t *testing.T, ctx context.Context) networkmapdb.NetworkMapDBStoreConn { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + c, err := pgstore.Pool.Acquire(ctx) + assert.NoError(t, err) + return pgstore.UsingConnection(c.Conn()) + case string(types.SqliteStoreEngine): + return sqlitestore.UsingConn() + } + log.Fatalf("unknown db engine kind %s", engine) + return nil +} + +func store(t *testing.T) networkmapdb.NetworkMapDBStore { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + return pgstore + case string(types.SqliteStoreEngine): + return sqlitestore + } + log.Fatalf("unknown db engine kind %s", engine) + return nil +} + +func execQuery(t *testing.T, ctx context.Context, q string) { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + _, err := pgstore.Pool.Exec(ctx, q) + assert.NoError(t, err) + case string(types.SqliteStoreEngine): + _, err := sqlitestore.Db.ExecContext(ctx, q) + assert.NoError(t, err) + } +} + +// use to parse time in time.RFC3339Nano format +// returns the time in the UTC time zone +func mustParseTime(t string) *time.Time { + tt, err := time.Parse(time.RFC3339Nano, t) + if err != nil { + panic(err) + } + + utc := tt.UTC() + return &utc +} diff --git a/integration_tests/management/network_map_db/nameserver_test.go b/integration_tests/management/network_map_db/nameserver_test.go new file mode 100644 index 000000000..d6243a6e3 --- /dev/null +++ b/integration_tests/management/network_map_db/nameserver_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNameServerGroups(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id) + VALUES('nsgroup-1','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-1')`) + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-2','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["group-one-resource-id","group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-1')`) + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-3','nsgroup-3-public',null,null,null,null,null,TRUE,FALSE,FALSE,'account-1')`) + + nsgroups, err := conn(t, ctx).GetNameServerGroups(ctx, "account-1") + assert.NoError(t, err) + + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-1", + PublicID: "nsgroup-1-public", + Name: "nsgroup-1", + Description: "nsgroup-1", + NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.31.2"), NSType: 1, Port: 53}}, + Groups: []string{"group-one-resource-id"}, + Domains: []string{"test-1.com"}, + Primary: true, + SearchDomainsEnabled: false, + Enabled: true, + }) + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-2", + PublicID: "nsgroup-2-public", + Name: "nsgroup-2", + Description: "nsgroup-2", + NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.32.3"), NSType: 1, Port: 53}}, + Groups: []string{"group-one-resource-id", "group-no-resources-id"}, + Domains: []string{"test-1.com", "test-2.com"}, + Primary: true, + SearchDomainsEnabled: false, + Enabled: true, + }) + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-3", + PublicID: "nsgroup-3-public", + Primary: false, + SearchDomainsEnabled: false, + Enabled: true, + }) +} diff --git a/integration_tests/management/network_map_db/network_map_data.sql b/integration_tests/management/network_map_db/network_map_data.sql new file mode 100644 index 000000000..d94e2f4aa --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data.sql @@ -0,0 +1,108 @@ +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-33','network-331','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]', + true, 86400000000000, false, + 86400000000000, null, '["33-group-one-resource-id"]', false, + false, 'disabled', false, false); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-one-resource-id','account-33','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-two-resources-id','account-33','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','33-group-two-resources-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-no-resources-id','account-33','group-3-name', null,'33-group-no-resources-id-public'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-331','33-group-one-resource-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-332','33-group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-333','33-group-two-resources-id'); +insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-331','account-33','key-331','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-1.netbird.services', + '0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.148.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-332','account-33','key-332','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-2.netbird.services', + '0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0, + 'DE','Berlin','"46.201.149.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-333','account-33','key-333','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-3.netbird.services', + '0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.150.187"'); + +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-331','account-33','test-331.com',true,true,'["33-group-one-resource-id"]'); +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-332','account-33','disabled-331.com',false,true,'["33-group-one-resource-id"]'); +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-333','account-33','search-off-331.com',true,false,'["33-group-two-resources-id"]'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-333','account-33','zone-332','test.disabled-331.com','A',1800,'1.1.1.9'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-334','account-33','zone-333','test.search-off-331.com','A',1800,'1.1.1.3'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-335','account-33','zone-333','alias.search-off-331.com','CNAME',1800,'test.search-off-331.com'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-331','account-33','zone-331','test.test-331.com','A',1800,'1.1.1.1'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-332','account-33','zone-331','test2.test-331.com','A',1800,'1.1.1.2'); + +insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-331','account-33','test-331.com','target-1.cluster.local'); + +insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id) + VALUES('nsgroup-331','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["33-group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-33'); +insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-332','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["33-group-one-resource-id","33-group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-33'); + +insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-331','account-33','network-331','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE); +insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-332','account-33','network-332','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE); + +insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-331','account-33','public-id-1','peer-id-331','network-id-1',TRUE,999,TRUE,'["33-group-one-resource-id"]'); +insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-332','account-33','public-id-2','','network-id-2',TRUE,333,TRUE,'["33-group-two-resources-id","33-group-no-resources-id"]'); + +insert into networks (id, account_id, public_id) VALUES('network-331','account-33','network-1-public'); +insert into networks (id, account_id, public_id) VALUES('network-332','account-33','network-2-public'); + +insert into policies (id, public_id, account_id, enabled, source_posture_checks) + values('policy-331','policy-1-public','account-33',true,'["posture-checks-1","posture-checks-2"]'); +insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations, + source_resource, destination_resource, ports, port_ranges, + authorized_groups, authorized_user) + values('policy-331-rule-1','policy-331',true,'accept','tcp',true,'["33-group-one-resource-id","33-group-two-resources-id"]','["33-group-one-resource-id","33-group-two-resources-id"]', + '{"ID":"host-id-1","Type":"host"}','{"ID":"domain-331","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]', + '{"33-group-one-resource-id":["user-1", "user-2"]}','user-3'); + +insert into posture_checks (id, account_id, public_id, checks) + VALUES('posturecheck-331','account-33','posturecheck-1-public', + '{"NBVersionCheck":{"MinVersion":"0.25.0"}, + "OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}}, + "GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"}, + "PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}'); + +insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description, + peer, peer_groups, network_type, masquerade, metric, enabled, + groups, access_control_groups, skip_auto_apply) + VALUES('route-331','account-33','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-331-net-id','route-1', + 'peer-id-331','["33-group-one-resource-id"]',1,true,9999,true, + '["33-group-one-resource-id"]','["33-group-one-resource-id"]',false); + +insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain) + values('service-331','account-33',true,true,'["33-group-one-resource-id"]','test-1.com','test-332.com'); diff --git a/integration_tests/management/network_map_db/network_map_data_golden.json b/integration_tests/management/network_map_db/network_map_data_golden.json new file mode 100644 index 000000000..bb0ccd30b --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_golden.json @@ -0,0 +1,546 @@ +{ + "Peers": { + "peer-id-331": { + "ID": "peer-id-331", + "Key": "key-331", + "SSHKey": "ssh-key-1", + "DNSLabel": "peer-1", + "UserID": "user-id-1", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T13:25:59.12999Z", + "IP": "10.10.10.1", + "IPv6": "fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "ExtraDNSLabels": [ + "extra-peer-1" + ], + "Meta": { + "WtVersion": "0.76.0", + "GoOS": "linux", + "OSVersion": "26.4.1", + "KernelVersion": "6.8.0-134-generic", + "NetworkAddresses": [ + { + "NetIP": "fe80::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.16.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 1 + }, + "ProxyMeta": { + "Embedded": true, + "Cluster": "cluster-1.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.148.187" + } + }, + "peer-id-332": { + "ID": "peer-id-332", + "Key": "key-332", + "SSHKey": "ssh-key-2", + "DNSLabel": "peer-2", + "UserID": "user-id-2", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T14:25:59.12999Z", + "IP": "10.10.100.1", + "IPv6": "fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "ExtraDNSLabels": [ + "extra-peer-2" + ], + "Meta": { + "WtVersion": "0.76.1", + "GoOS": "linux", + "OSVersion": "26.4.2", + "KernelVersion": "6.8.0-135-generic", + "NetworkAddresses": [ + { + "NetIP": "fe81::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.17.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 0 + }, + "ProxyMeta": { + "Embedded": true, + "Cluster": "cluster-2.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.149.187" + } + }, + "peer-id-333": { + "ID": "peer-id-333", + "Key": "key-333", + "SSHKey": "ssh-key-3", + "DNSLabel": "peer-3", + "UserID": "user-id-3", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T12:25:59.12999Z", + "IP": "10.10.200.1", + "IPv6": "fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "ExtraDNSLabels": [ + "extra-peer-3" + ], + "Meta": { + "WtVersion": "0.76.2", + "GoOS": "linux", + "OSVersion": "26.4.3", + "KernelVersion": "6.8.0-136-generic", + "NetworkAddresses": [ + { + "NetIP": "fe82::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.18.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 1 + }, + "ProxyMeta": { + "Embedded": true, + "Cluster": "cluster-3.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.150.187" + } + } + }, + "Groups": { + "33-group-no-resources-id": { + "ID": "33-group-no-resources-id", + "Name": "group-3-name", + "PublicID": "33-group-no-resources-id-public", + "Peers": null, + "Resources": null + }, + "33-group-one-resource-id": { + "ID": "33-group-one-resource-id", + "Name": "group-1-name", + "PublicID": "group-one-resource-id-public", + "Peers": [ + "peer-id-331" + ], + "Resources": [ + { + "ID": "host-id-1", + "Type": "host" + } + ] + }, + "33-group-two-resources-id": { + "ID": "33-group-two-resources-id", + "Name": "group-2-name", + "PublicID": "33-group-two-resources-id-public", + "Peers": [ + "peer-id-332", + "peer-id-333" + ], + "Resources": [ + { + "ID": "subnet-id-1", + "Type": "subnet" + }, + { + "ID": "host-id-2", + "Type": "host" + } + ] + } + }, + "Policies": [ + { + "ID": "policy-331", + "PublicID": "policy-1-public", + "Enabled": true, + "SourcePostureChecks": [ + "posture-checks-1", + "posture-checks-2" + ], + "Rules": [ + { + "ID": "policy-331", + "PolicyID": "policy-331", + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Bidirectional": true, + "Sources": [ + "33-group-one-resource-id", + "33-group-two-resources-id" + ], + "Destinations": [ + "33-group-one-resource-id", + "33-group-two-resources-id" + ], + "SourceResource": { + "ID": "host-id-1", + "Type": "host" + }, + "DestinationResource": { + "ID": "domain-331", + "Type": "domain" + }, + "Ports": [ + "8080", + "8443" + ], + "PortRanges": [ + { + "Start": 8080, + "End": 8090 + } + ], + "AuthorizedGroups": { + "33-group-one-resource-id": [ + "user-1", + "user-2" + ] + }, + "AuthorizedUser": "user-3" + } + ] + } + ], + "Routes": [ + { + "ID": "route-331", + "AccountID": "account-33", + "PublicID": "route-1-public", + "Network": "172.0.0.0/16", + "Domains": [ + "test-1.com" + ], + "KeepRoute": true, + "NetID": "route-331-net-id", + "Description": "route-1", + "Peer": "peer-id-331", + "PeerID": "peer-id-331", + "PeerGroups": [ + "33-group-one-resource-id" + ], + "NetworkType": 1, + "Masquerade": true, + "Metric": 9999, + "Enabled": true, + "Groups": [ + "33-group-one-resource-id" + ], + "AccessControlGroups": [ + "33-group-one-resource-id" + ], + "SkipAutoApply": false + } + ], + "NameServerGroups": [ + { + "ID": "nsgroup-331", + "PublicID": "nsgroup-1-public", + "Name": "nsgroup-1", + "Description": "nsgroup-1", + "NameServers": [ + { + "IP": "192.168.31.2", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "33-group-one-resource-id" + ], + "Primary": true, + "Domains": [ + "test-1.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + }, + { + "ID": "nsgroup-332", + "PublicID": "nsgroup-2-public", + "Name": "nsgroup-2", + "Description": "nsgroup-2", + "NameServers": [ + { + "IP": "192.168.32.3", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "33-group-one-resource-id", + "33-group-no-resources-id" + ], + "Primary": true, + "Domains": [ + "test-1.com", + "test-2.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + } + ], + "NetworkResources": [ + { + "ID": "net-resource-331", + "NetworkID": "network-331", + "AccountID": "account-33", + "PublicID": "net-resource-public-1", + "Name": "network-resource-1", + "Description": "network-resource-1", + "Type": "subnet", + "Address": "", + "Domain": "", + "Prefix": "10.0.0.0/16", + "Enabled": true + }, + { + "ID": "net-resource-332", + "NetworkID": "network-332", + "AccountID": "account-33", + "PublicID": "net-resource-public-2", + "Name": "network-resource-2", + "Description": "network-resource-2", + "Type": "domain", + "Address": "", + "Domain": "test.com", + "Prefix": "", + "Enabled": true + } + ], + "Network": { + "Identifier": "network-331", + "Net": { + "IP": "100.103.0.0", + "Mask": "//8AAA==" + }, + "NetV6": { + "IP": "fdde:e995:fd38:a465::", + "Mask": "//////////8AAAAAAAAAAA==" + }, + "Dns": "", + "Serial": 1 + }, + "DNSSettings": { + "DisabledManagementGroups": [ + "disabled-group-1", + "disabled-group-2" + ] + }, + "AccountSettings": { + "PeerLoginExpirationEnabled": true, + "PeerLoginExpiration": 86400000000000, + "PeerInactivityExpirationEnabled": false, + "PeerInactivityExpiration": 86400000000000, + "DNSDomain": "", + "IPv6EnabledGroups": [ + "33-group-one-resource-id" + ], + "RoutingPeerDNSResolutionEnabled": false, + "LazyConnectionEnabled": false, + "AutoUpdateVersion": "disabled", + "AutoUpdateAlways": false, + "MetricsPushEnabled": false + }, + "PostureChecks": { + "posturecheck-331": { + "ID": "posturecheck-331", + "Checks": { + "NBVersionCheck": { + "MinVersion": "0.25.0" + }, + "OSVersionCheck": { + "Android": null, + "Darwin": { + "MinVersion": "12.0" + }, + "Ios": null, + "Linux": null, + "Windows": null + }, + "GeoLocationCheck": { + "Locations": [ + { + "CountryCode": "FI", + "CityName": "" + } + ], + "Action": "allow" + }, + "PeerNetworkRangeCheck": { + "Action": "deny", + "Ranges": [ + "192.168.0.1/24" + ] + }, + "ProcessCheck": null + } + } + }, + "PostureValidation": null, + "AllowedUserIDs": {}, + "NetworkXIDToPublicID": { + "network-331": "network-1-public", + "network-332": "network-2-public" + }, + "PostureCheckXIDToPublicID": { + "posturecheck-331": "posturecheck-1-public" + }, + "ValidatedPeers": { + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {} + }, + "ResourcePolicies": {}, + "Routers": { + "network-id-1": { + "peer-id-331": { + "PublicID": "public-id-1", + "PeerGroups": [ + "33-group-one-resource-id" + ], + "Masquerade": true, + "Metric": 999, + "Enabled": true + } + }, + "network-id-2": { + "peer-id-332": { + "PublicID": "public-id-2", + "PeerGroups": [ + "33-group-two-resources-id", + "33-group-no-resources-id" + ], + "Masquerade": true, + "Metric": 333, + "Enabled": true + }, + "peer-id-333": { + "PublicID": "public-id-2", + "PeerGroups": [ + "33-group-two-resources-id", + "33-group-no-resources-id" + ], + "Masquerade": true, + "Metric": 333, + "Enabled": true + } + } + }, + "GroupIDToUserIDs": {}, + "DNSDomain": "", + "ProxyTargetedDomainResourceIDs": {}, + "AppliedZoneCandidates": [ + { + "DistributionGroups": [ + "33-group-one-resource-id" + ], + "Zone": { + "Domain": "test-331.com.", + "Records": [ + { + "Name": "test.test-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.1" + }, + { + "Name": "test2.test-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.2" + } + ], + "SearchDomainDisabled": false, + "NonAuthoritative": true + } + }, + { + "DistributionGroups": [ + "33-group-two-resources-id" + ], + "Zone": { + "Domain": "search-off-331.com.", + "Records": [ + { + "Name": "test.search-off-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.3" + }, + { + "Name": "alias.search-off-331.com.", + "Type": 5, + "Class": "IN", + "TTL": 1800, + "RData": "test.search-off-331.com." + } + ], + "SearchDomainDisabled": true, + "NonAuthoritative": true + } + } + ], + "PrivateServiceCandidates": null, + "Services": null +} \ No newline at end of file diff --git a/integration_tests/management/network_map_db/network_map_data_test.go b/integration_tests/management/network_map_db/network_map_data_test.go new file mode 100644 index 000000000..00c0ec03f --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + _ "embed" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/types" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +//go:embed network_map_data.sql +var nmapData string + +//go:embed network_map_data_golden.json +var goldenNMap string + +const EnvUpdateGoldenData = "NMAP_UPDATE_GOLDEN_DATA" + +func TestGetNetworkMapData(t *testing.T) { + ctx := context.TODO() + + // The two mocks are generated by different mock frameworks, so each needs a + // controller of its own kind. + extraSettingsManager := settings.NewMockManager(gomock.NewController(t)) + extraSettingsManager.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil) + + peerValidators := integrated_validator.NewMockIntegratedValidator(gomock.NewController(t)) + peerValidators.EXPECT().GetValidatedPeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return( + map[string]struct{}{ + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {}, + }, nil) + + storeImpl := networkmapdb.NetworkMapDBStoreImpl{ + Store: store(t), + ExtraSettingsManager: extraSettingsManager, + IntegratedPeerValidator: peerValidators, + } + + for _, query := range strings.Split(nmapData, ";") { + if err := store(t).Exec(ctx, query); err != nil { + log.Fatalf("error initializing nmap test: %s", err.Error()) + } + } + + nmap, err := storeImpl.GetNetworkMapData(ctx, "account-33") + assert.NoError(t, err) + + serializedNMap, err := json.MarshalIndent(nmap, "", " ") + assert.NoError(t, err) + + if _, ok := os.LookupEnv(EnvUpdateGoldenData); ok { + _, filename, _, _ := runtime.Caller(0) + tosavepath := filepath.Join(filepath.Dir(filename), "network_map_data_golden.json") + err = os.WriteFile(tosavepath, serializedNMap, 0644) + assert.NoError(t, err) + goldenNMap = string(serializedNMap) + } + assert.Equal(t, goldenNMap, string(serializedNMap)) +} diff --git a/integration_tests/management/network_map_db/network_resource_test.go b/integration_tests/management/network_map_db/network_resource_test.go new file mode 100644 index 000000000..4325ed3ba --- /dev/null +++ b/integration_tests/management/network_map_db/network_resource_test.go @@ -0,0 +1,65 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetworkResources(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-1','account-1','network-1','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE)`) + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-2','account-1','network-2','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE)`) + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-3','account-1','network-3','net-resource-public-3','network-resource-3','network-resource-3','host','','"10.0.0.1/32"',TRUE)`) + + resources, err := conn(t, ctx).GetNetworkResources(ctx, "account-1") + assert.NoError(t, err) + + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-1", + AccountID: "account-1", + NetworkID: "network-1", + PublicID: "net-resource-public-1", + Name: "network-resource-1", + Description: "network-resource-1", + Type: "subnet", + Domain: "", + Prefix: netip.MustParsePrefix("10.0.0.0/16"), + Enabled: true, + }) + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-2", + AccountID: "account-1", + NetworkID: "network-2", + PublicID: "net-resource-public-2", + Name: "network-resource-2", + Description: "network-resource-2", + Type: "domain", + Domain: "test.com", + Enabled: true, + }) + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-3", + AccountID: "account-1", + NetworkID: "network-3", + PublicID: "net-resource-public-3", + Name: "network-resource-3", + Description: "network-resource-3", + Type: "host", + Domain: "", + Prefix: netip.MustParsePrefix("10.0.0.1/32"), + Enabled: true, + }) +} diff --git a/integration_tests/management/network_map_db/network_router_test.go b/integration_tests/management/network_map_db/network_router_test.go new file mode 100644 index 000000000..fa7ea2a04 --- /dev/null +++ b/integration_tests/management/network_map_db/network_router_test.go @@ -0,0 +1,33 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetworkRouters(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-1','account-1','public-id-1','peer-id-1','network-id-1',TRUE,999,TRUE,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-2','account-1','public-id-2','','network-id-2',TRUE,333,TRUE,'["group-two-resources-id","group-no-resources-id"]')`) + + routers, err := conn(t, ctx).GetNetworkRouters(ctx, "account-1") + assert.NoError(t, err) + assert.NotEmpty(t, routers) + + assert.Equal(t, routers["network-id-1"], + map[string]*nmdata.NetworkRouter{"peer-id-1": {PublicID: "public-id-1", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{"group-one-resource-id"}}}) + assert.Equal(t, routers["network-id-2"], + map[string]*nmdata.NetworkRouter{ + "peer-id-2": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}, + "peer-id-3": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}}) +} diff --git a/integration_tests/management/network_map_db/network_test.go b/integration_tests/management/network_map_db/network_test.go new file mode 100644 index 000000000..fbccee504 --- /dev/null +++ b/integration_tests/management/network_map_db/network_test.go @@ -0,0 +1,56 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "encoding/json" + "net" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetwork(t *testing.T) { + ctx := context.TODO() + + network, err := conn(t, ctx).GetNetwork(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, network, nmdata.Network{ + Identifier: "network-1", + Net: mustParseCIDR("100.103.0.0/16"), + NetV6: mustParseCIDR("fdde:e995:fd38:a465::/64"), + Serial: 1, + }) + + network, err = conn(t, ctx).GetNetwork(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, network, nmdata.Network{ + Identifier: "network-2", + Net: mustParseCIDR("110.0.0.0/16"), + NetV6: mustParseCIDR("fddf:e995:fd38:a465::/64"), + Serial: 2, + }) +} + +func mustParseCIDR(s string) net.IPNet { + var toret net.IPNet + + _, net, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + + jn, err := json.Marshal(net) + if err != nil { + panic(err) + } + + err = json.Unmarshal(jn, &toret) + if err != nil { + panic(err) + } + + return toret +} diff --git a/integration_tests/management/network_map_db/networks_test.go b/integration_tests/management/network_map_db/networks_test.go new file mode 100644 index 000000000..5af771522 --- /dev/null +++ b/integration_tests/management/network_map_db/networks_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetNetworks(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into networks (id, account_id, public_id) VALUES('network-1','account-1','network-1-public')`) + execQuery(t, ctx, + `insert into networks (id, account_id, public_id) VALUES('network-2','account-1','network-2-public')`) + + networksIdx, err := conn(t, ctx).GetNetworkXIDToPublicIdMap(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, networksIdx, map[string]string{ + "network-1": "network-1-public", + "network-2": "network-2-public", + }) +} diff --git a/integration_tests/management/network_map_db/peer_test.go b/integration_tests/management/network_map_db/peer_test.go new file mode 100644 index 000000000..e33c3ea3a --- /dev/null +++ b/integration_tests/management/network_map_db/peer_test.go @@ -0,0 +1,166 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetPeers(t *testing.T) { + ctx := context.TODO() + + peers, clusterToPeersIdx, err := conn(t, ctx).GetPeers(ctx, "account-1") + assert.NoError(t, err) + + // shouldn't be returned in the index, as it's not connected + execQuery(t, ctx, + `insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected) + values('peer-4','account-1','key-4','ssh-key-4',true,false)`) + // shouldn't be returned in the index as it doesn't have cluster set + execQuery(t, ctx, + `insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected) + values('peer-5','account-1','key-5','ssh-key-5',false,true)`) + + peer1 := nmdata.Peer{ + ID: "peer-id-1", + Key: "key-1", + SSHKey: "ssh-key-1", + DNSLabel: "peer-1", + ExtraDNSLabels: []string{"extra-peer-1"}, + UserID: "user-id-1", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T13:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.10.1"), + IPv6: netip.MustParseAddr("fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.0", + GoOS: "linux", + OSVersion: "26.4.1", + KernelVersion: "6.8.0-134-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe80::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.16.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 1, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-1.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.148.187"), + }, + } + peer2 := nmdata.Peer{ + ID: "peer-id-2", + Key: "key-2", + SSHKey: "ssh-key-2", + DNSLabel: "peer-2", + ExtraDNSLabels: []string{"extra-peer-2"}, + UserID: "user-id-2", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T14:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.100.1"), + IPv6: netip.MustParseAddr("fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.1", + GoOS: "linux", + OSVersion: "26.4.2", + KernelVersion: "6.8.0-135-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe81::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.17.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 0, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-2.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.149.187"), + }, + } + peer3 := nmdata.Peer{ + ID: "peer-id-3", + Key: "key-3", + SSHKey: "ssh-key-3", + DNSLabel: "peer-3", + ExtraDNSLabels: []string{"extra-peer-3"}, + UserID: "user-id-3", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T12:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.200.1"), + IPv6: netip.MustParseAddr("fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.2", + GoOS: "linux", + OSVersion: "26.4.3", + KernelVersion: "6.8.0-136-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe82::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.18.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 1, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-3.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.150.187"), + }, + } + + assert.Contains(t, peers, peer1) + assert.Contains(t, peers, peer2) + assert.Contains(t, peers, peer3) + + assert.Equal(t, clusterToPeersIdx, map[string][]*nmdata.Peer{ + "cluster-1.netbird.services": {&peer1}, + "cluster-2.netbird.services": {&peer2}, + "cluster-3.netbird.services": {&peer3}, + }) +} diff --git a/integration_tests/management/network_map_db/pg_test_store.go b/integration_tests/management/network_map_db/pg_test_store.go new file mode 100644 index 000000000..1710747b5 --- /dev/null +++ b/integration_tests/management/network_map_db/pg_test_store.go @@ -0,0 +1,121 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/google/uuid" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + gormstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/testutil" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func createPGTestStore(baseData string) (*networkmap_pgsql.PgStore, func()) { + _, tmpdsn, err := testutil.CreatePostgresTestContainer() + if err != nil { + log.Fatalf("error starting postres container %v", err) + } + + var db *gorm.DB + for i := range 5 { + db, err = gorm.Open(postgres.Open(tmpdsn), &gorm.Config{}) + + if err == nil { + break + } + + if i < 5 { + waitTime := time.Duration(100*(i+1)) * time.Millisecond + time.Sleep(waitTime) + continue + } + + log.Fatalf("error connecting to postres db %v", err) + } + + var cleanup func() + dsn, cleanup, err := createRandomDB(tmpdsn, db) + sqlDB, _ := db.DB() + if sqlDB != nil { + sqlDB.Close() + } + if err != nil { + log.Fatalf("error creating postres db %v", err) + } + + _, err = gormstore.NewPostgresqlStoreForTests(context.TODO(), dsn, nil, false) + if err != nil { + log.Fatalf("error running migrations %v", err) + } + + ctx := context.TODO() + pgstore, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn) + if err != nil { + log.Fatal("error creating postgres store %w", err) + } + + for _, query := range strings.Split(baseData, ";") { + if _, err := pgstore.Pool.Exec(ctx, query); err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + } + } + + return pgstore, cleanup +} + +func createRandomDB(dsn string, db *gorm.DB) (string, func(), error) { + dbName := fmt.Sprintf("test_db_%s", strings.ReplaceAll(uuid.New().String(), "-", "_")) + + if err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil { + return "", nil, fmt.Errorf("failed to create database: %v", err) + } + + originalDSN := dsn + + cleanup := func() { + var dropDB *gorm.DB + var err error + + dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{ + SkipDefaultTransaction: true, + PrepareStmt: false, + }) + if err != nil { + log.Errorf("failed to connect for dropping database %s: %v", dbName, err) + return + } + defer func() { + if sqlDB, _ := dropDB.DB(); sqlDB != nil { + sqlDB.Close() + } + }() + + if sqlDB, _ := dropDB.DB(); sqlDB != nil { + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(0) + sqlDB.SetConnMaxLifetime(time.Second) + } + + err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error + + if err != nil { + log.Errorf("failed to drop database %s: %v", dbName, err) + } + } + + return replaceDBName(dsn, dbName), cleanup, nil +} + +func replaceDBName(dsn, newDBName string) string { + re := regexp.MustCompile(`(?P
[:/@])(?P[^/?]+)(?P\?|$)`)
+	return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
+}
diff --git a/integration_tests/management/network_map_db/policy_test.go b/integration_tests/management/network_map_db/policy_test.go
new file mode 100644
index 000000000..1f4c543da
--- /dev/null
+++ b/integration_tests/management/network_map_db/policy_test.go
@@ -0,0 +1,146 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"testing"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetPolicies(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
+		 values('policy-1','policy-1-public','account-1',true,'["posture-checks-1","posture-checks-2"]')`)
+	execQuery(t, ctx,
+		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
+		                           source_resource, destination_resource, ports, port_ranges,
+								   authorized_groups, authorized_user)
+		 values('policy-1-rule-1','policy-1',true,'accept','tcp',true,'["group-one-resource-id","group-two-resources-id"]','["group-one-resource-id","group-two-resources-id"]',
+		        '{"ID":"host-id-1","Type":"host"}','{"ID":"domain-1","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
+				'{"group-one-resource-id":["user-1", "user-2"]}','user-3')`)
+	execQuery(t, ctx,
+		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
+		 values('policy-2','policy-2-public','account-1',true,'["posture-checks-3","posture-checks-4"]')`)
+	execQuery(t, ctx,
+		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
+		                           source_resource, destination_resource, ports, port_ranges,
+								   authorized_groups, authorized_user)
+		 values('policy-2-rule-1','policy-2',true,'accept','tcp',true,'["group-one-resource-id"]','["group-two-resources-id"]',
+		        '{"ID":"host-id-3","Type":"host"}','{"ID":"domain-3","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
+				'{"group-one-resource-id":["user-6", "user-7"]}','user-8')`)
+	// policy with a rule with null fields
+	execQuery(t, ctx,
+		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
+		 values('policy-3','policy-3-public','account-1',true,null)`)
+	execQuery(t, ctx,
+		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
+		                           source_resource, destination_resource, ports, port_ranges,
+								   authorized_groups, authorized_user)
+		 values('policy-3-rule-1','policy-3',true,null,null,null,null,null,null,null,null,null,null,null)`)
+	// policy with a disabled rule, destination resource and groups should not be in indexes
+	execQuery(t, ctx,
+		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
+		 values('policy-4','policy-4-public','account-1',true,null)`)
+	execQuery(t, ctx,
+		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
+		                           source_resource, destination_resource, ports, port_ranges,
+								   authorized_groups, authorized_user)
+		 values('policy-4-rule-1','policy-4',false,null,null,null,null,'["group-two-resources-id"]',
+		        null,'{"ID":"domain-3","Type":"domain"}',null,null,null,null)`)
+
+	policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := conn(t, ctx).GetPolicies(ctx, "account-1")
+	assert.NoError(t, err)
+
+	assert.Contains(t, policies, nmdata.Policy{
+		ID:                  "policy-1",
+		PublicID:            "policy-1-public",
+		Enabled:             true,
+		SourcePostureChecks: []string{"posture-checks-1", "posture-checks-2"},
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  "policy-1",
+				PolicyID:            "policy-1",
+				Enabled:             true,
+				Action:              "accept",
+				Protocol:            "tcp",
+				Bidirectional:       true,
+				Sources:             []string{"group-one-resource-id", "group-two-resources-id"},
+				Destinations:        []string{"group-one-resource-id", "group-two-resources-id"},
+				SourceResource:      nmdata.Resource{ID: "host-id-1", Type: "host"},
+				DestinationResource: nmdata.Resource{ID: "domain-1", Type: "domain"},
+				Ports:               []string{"8080", "8443"},
+				PortRanges:          []nmdata.RulePortRange{{Start: 8080, End: 8090}},
+				AuthorizedGroups:    map[string][]string{"group-one-resource-id": {"user-1", "user-2"}},
+				AuthorizedUser:      "user-3",
+			},
+		},
+	})
+
+	assert.Contains(t, policies, nmdata.Policy{
+		ID:                  "policy-2",
+		PublicID:            "policy-2-public",
+		Enabled:             true,
+		SourcePostureChecks: []string{"posture-checks-3", "posture-checks-4"},
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  "policy-2",
+				PolicyID:            "policy-2",
+				Enabled:             true,
+				Action:              "accept",
+				Protocol:            "tcp",
+				Bidirectional:       true,
+				Sources:             []string{"group-one-resource-id"},
+				Destinations:        []string{"group-two-resources-id"},
+				SourceResource:      nmdata.Resource{ID: "host-id-3", Type: "host"},
+				DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
+				Ports:               []string{"8080", "8443"},
+				PortRanges:          []nmdata.RulePortRange{{Start: 8080, End: 8090}},
+				AuthorizedGroups:    map[string][]string{"group-one-resource-id": {"user-6", "user-7"}},
+				AuthorizedUser:      "user-8",
+			},
+		},
+	})
+
+	assert.Contains(t, policies, nmdata.Policy{
+		ID:                  "policy-3",
+		PublicID:            "policy-3-public",
+		Enabled:             true,
+		SourcePostureChecks: nil,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:       "policy-3",
+				PolicyID: "policy-3",
+				Enabled:  true,
+			},
+		},
+	})
+	assert.Contains(t, policies, nmdata.Policy{
+		ID:                  "policy-4",
+		PublicID:            "policy-4-public",
+		Enabled:             true,
+		SourcePostureChecks: nil,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  "policy-4",
+				PolicyID:            "policy-4",
+				Enabled:             false,
+				Destinations:        []string{"group-two-resources-id"},
+				DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
+			},
+		},
+	})
+
+	assert.Equal(t, policyToDestinationGroupIdx, map[string]map[string]any{
+		"policy-1": {"group-one-resource-id": struct{}{}, "group-two-resources-id": struct{}{}},
+		"policy-2": {"group-two-resources-id": struct{}{}},
+	})
+	assert.Equal(t, policyToDestinationResourceIdx, map[string]map[string]any{
+		"policy-1": {"domain-1": struct{}{}},
+		"policy-2": {"domain-3": struct{}{}},
+	})
+}
diff --git a/integration_tests/management/network_map_db/posture_test.go b/integration_tests/management/network_map_db/posture_test.go
new file mode 100644
index 000000000..2b4bb3f3d
--- /dev/null
+++ b/integration_tests/management/network_map_db/posture_test.go
@@ -0,0 +1,61 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"net/netip"
+	"testing"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetPostureChecks(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into posture_checks (id, account_id, public_id, checks)
+		VALUES('posturecheck-1','account-1','posturecheck-1-public',
+		'{"NBVersionCheck":{"MinVersion":"0.25.0"},
+		  "OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}},
+		  "GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"},
+		  "PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}')`)
+
+	execQuery(t, ctx,
+		`insert into posture_checks (id, account_id, public_id, checks)
+		VALUES('posturecheck-2','account-1','posturecheck-2-public',
+		'{"NBVersionCheck":{"MinVersion":"0.25.0"},
+		  "OSVersionCheck":{"Android":{"MinVersion":"0"}},
+		  "GeoLocationCheck":{"Locations":[{"CountryCode":"US","CityName":"Harker Heights"}],"Action":"allow"},
+		  "PeerNetworkRangeCheck":{"Action":"allow","Ranges":["0.0.0.0/0"]}}')`)
+	execQuery(t, ctx,
+		`insert into posture_checks (id, account_id, public_id, checks)
+		VALUES('posturecheck-3','account-1','posturecheck-3-public', null)`)
+
+	postureChecks, idToPublicIDIdx, err := conn(t, ctx).GetPostureChecks(ctx, "account-1")
+	assert.NoError(t, err)
+	assert.Equal(t, idToPublicIDIdx, map[string]string{
+		"posturecheck-1": "posturecheck-1-public",
+		"posturecheck-2": "posturecheck-2-public",
+		"posturecheck-3": "posturecheck-3-public",
+	})
+	assert.Contains(t, postureChecks, nmdata.PostureChecks{
+		ID: "posturecheck-1",
+		Checks: nmdata.ChecksDefinition{
+			NBVersionCheck:        &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
+			OSVersionCheck:        &nmdata.OSVersionCheck{Darwin: &nmdata.MinVersionCheck{MinVersion: "12.0"}},
+			GeoLocationCheck:      &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "FI"}}, Action: "allow"},
+			PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "deny", Ranges: []netip.Prefix{netip.MustParsePrefix("192.168.0.1/24")}},
+		}})
+	assert.Contains(t, postureChecks, nmdata.PostureChecks{
+		ID: "posturecheck-2",
+		Checks: nmdata.ChecksDefinition{
+			NBVersionCheck:        &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
+			OSVersionCheck:        &nmdata.OSVersionCheck{Android: &nmdata.MinVersionCheck{MinVersion: "0"}},
+			GeoLocationCheck:      &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "US", CityName: "Harker Heights"}}, Action: "allow"},
+			PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "allow", Ranges: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}},
+		}})
+	assert.Contains(t, postureChecks, nmdata.PostureChecks{
+		ID: "posturecheck-3"})
+}
diff --git a/integration_tests/management/network_map_db/route_test.go b/integration_tests/management/network_map_db/route_test.go
new file mode 100644
index 000000000..12e9302f9
--- /dev/null
+++ b/integration_tests/management/network_map_db/route_test.go
@@ -0,0 +1,87 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"net/netip"
+	"testing"
+
+	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetRoutes(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
+	                         peer, peer_groups, network_type, masquerade, metric, enabled, 
+	                         groups, access_control_groups, skip_auto_apply)
+		VALUES('route-1','account-1','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-1-net-id','route-1',
+		        'peer-id-1','["group-one-resource-id"]',1,true,9999,true,
+				'["group-one-resource-id"]','["group-one-resource-id"]',false)`)
+	execQuery(t, ctx,
+		`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
+	                         peer, peer_groups, network_type, masquerade, metric, enabled, 
+	                         groups, access_control_groups, skip_auto_apply)
+		VALUES('route-2','account-1','route-2-public','"172.10.0.0/16"','["test-1.com","test-2.com"]',true,'route-2-net-id','route-2',
+		        'peer-id-2','["group-two-resources-id"]',1,true,9999,true,
+				'["group-two-resources-id"]','["group-two-resources-id"]',false)`)
+	execQuery(t, ctx,
+		`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
+	                         peer, peer_groups, network_type, masquerade, metric, enabled, 
+	                         groups, access_control_groups, skip_auto_apply)
+		VALUES('route-3','account-1','route-3-public',null,null,null,null,'route-3',
+		        null,null,null,null,null,null,null,null,null)`)
+
+	routes, err := conn(t, ctx).GetRoutes(ctx, "account-1")
+	assert.NoError(t, err)
+	assert.Contains(t, routes, nmdata.Route{
+		ID:                  "route-1",
+		AccountID:           "account-1",
+		PublicID:            "route-1-public",
+		Network:             netip.MustParsePrefix("172.0.0.0/16"),
+		Domains:             domain.List{"test-1.com"},
+		KeepRoute:           true,
+		NetID:               "route-1-net-id",
+		Description:         "route-1",
+		Peer:                "peer-id-1",
+		PeerID:              "peer-id-1",
+		PeerGroups:          []string{"group-one-resource-id"},
+		NetworkType:         1,
+		Masquerade:          true,
+		Metric:              9999,
+		Enabled:             true,
+		Groups:              []string{"group-one-resource-id"},
+		AccessControlGroups: []string{"group-one-resource-id"},
+		SkipAutoApply:       false,
+	})
+	assert.Contains(t, routes, nmdata.Route{
+		ID:                  "route-2",
+		AccountID:           "account-1",
+		PublicID:            "route-2-public",
+		Network:             netip.MustParsePrefix("172.10.0.0/16"),
+		Domains:             domain.List{"test-1.com", "test-2.com"},
+		KeepRoute:           true,
+		NetID:               "route-2-net-id",
+		Description:         "route-2",
+		Peer:                "peer-id-2",
+		PeerID:              "peer-id-2",
+		PeerGroups:          []string{"group-two-resources-id"},
+		NetworkType:         1,
+		Masquerade:          true,
+		Metric:              9999,
+		Enabled:             true,
+		Groups:              []string{"group-two-resources-id"},
+		AccessControlGroups: []string{"group-two-resources-id"},
+		SkipAutoApply:       false,
+	})
+	assert.Contains(t, routes, nmdata.Route{
+		ID:          "route-3",
+		AccountID:   "account-1",
+		PublicID:    "route-3-public",
+		Description: "route-3",
+	})
+}
diff --git a/integration_tests/management/network_map_db/service_test.go b/integration_tests/management/network_map_db/service_test.go
new file mode 100644
index 000000000..effc7a707
--- /dev/null
+++ b/integration_tests/management/network_map_db/service_test.go
@@ -0,0 +1,109 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"database/sql"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+func TestGetPrivateServices(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
+		 values('service-1','account-1',true,true,'["group-one-resource-id"]','test-1.com','test-2.com')`)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
+		 values('service-2','account-1',true,true,'["group-one-resource-id","group-two-resources-id"]','test-3.com','test-4.com')`)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
+		 values('service-3','account-1',null,null,null,null,null)`)
+
+	services, err := conn(t, ctx).GetPrivateServices(ctx, "account-1")
+	assert.NoError(t, err)
+	assert.Contains(t, services, networkmapdb.Service{
+		Enabled:      sql.NullBool{Bool: true, Valid: true},
+		Private:      sql.NullBool{Bool: true, Valid: true},
+		AccessGroups: []string{"group-one-resource-id"},
+		ProxyCluster: sql.NullString{String: "test-1.com", Valid: true},
+		Domain:       sql.NullString{String: "test-2.com", Valid: true},
+	})
+	assert.Contains(t, services, networkmapdb.Service{
+		Enabled:      sql.NullBool{Bool: true, Valid: true},
+		Private:      sql.NullBool{Bool: true, Valid: true},
+		AccessGroups: []string{"group-one-resource-id", "group-two-resources-id"},
+		ProxyCluster: sql.NullString{String: "test-3.com", Valid: true},
+		Domain:       sql.NullString{String: "test-4.com", Valid: true},
+	})
+	assert.Contains(t, services, networkmapdb.Service{
+		Enabled:      sql.NullBool{Bool: false, Valid: false},
+		Private:      sql.NullBool{Bool: false, Valid: false},
+		AccessGroups: []string{},
+		ProxyCluster: sql.NullString{String: "", Valid: false},
+		Domain:       sql.NullString{String: "", Valid: false},
+	})
+}
+
+func TestGetProxyTargetedDomainResourceIDs(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-4','account-1',true,false)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-1','account-1','service-4',true,'domain')`)
+	// id shouldn't be returned as the taget_type is not "domain"
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-2','account-1','service-4',true,'cluster')`)
+	// id shouldn't be included as the target is disabled
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-3','account-1','service-4',false,'domain')`)
+	// id shouldn't be included as the service is disabled
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-5','account-1',false,false)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-4','account-1','service-5',false,'domain')`)
+	// id shouldn't be included as the service is terminated (explicitly)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-6','account-1',true,true)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-5','account-1','service-6',true,'domain')`)
+	// id shouldn't be included as the service is terminated (implicitly)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-7','account-1',true,null)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-6','account-1','service-7',true,'domain')`)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-8','account-1',true,false)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-7','account-1','service-8',true,'domain')`)
+	// id shouldn't be returned as the taget_id is null
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values(null,'account-1','service-4',true,'cluster')`)
+
+	servtargetedDomains, err := conn(t, ctx).GetProxyTargetedDomainResourceIDs(ctx, "account-1")
+	assert.NoError(t, err)
+	assert.Equal(t, servtargetedDomains, map[string]struct{}{
+		"target-1": {},
+		"target-6": {},
+		"target-7": {},
+	})
+}
diff --git a/integration_tests/management/network_map_db/sqlite_test_store.go b/integration_tests/management/network_map_db/sqlite_test_store.go
new file mode 100644
index 000000000..1c70c93d4
--- /dev/null
+++ b/integration_tests/management/network_map_db/sqlite_test_store.go
@@ -0,0 +1,48 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"fmt"
+	"runtime"
+	"strings"
+
+	networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite"
+	gormstore "github.com/netbirdio/netbird/management/server/store"
+	"github.com/netbirdio/netbird/management/server/types"
+	log "github.com/sirupsen/logrus"
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+)
+
+func createSqliteTestStore(baseData string) (*networkmap_sqlite.SqliteStore, func()) {
+	storeSqliteFileName := ":memory:"
+	storeStr := fmt.Sprintf("%s?cache=shared", storeSqliteFileName)
+	if runtime.GOOS == "windows" {
+		// Vo avoid `The process cannot access the file because it is being used by another process` on Windows
+		storeStr = storeSqliteFileName
+	}
+
+	db, err := gorm.Open(sqlite.Open(storeStr), &gorm.Config{})
+	if err != nil {
+		log.Fatalf("error initializing db: %s", err.Error())
+	}
+	_, err = gormstore.NewSqlStore(context.TODO(), db, types.SqliteStoreEngine, nil, false)
+	if err != nil {
+		log.Fatalf("error initializing db: %s", err.Error())
+	}
+
+	sqldb, err := db.DB()
+	if err != nil {
+		log.Fatalf("error initializing db: %s", err.Error())
+
+	}
+	for _, query := range strings.Split(baseData, ";") {
+		if _, err := sqldb.Exec(query); err != nil {
+			log.Fatalf("error initializing db: %s", err.Error())
+		}
+	}
+
+	return &networkmap_sqlite.SqliteStore{Db: sqldb}, func() {}
+}
diff --git a/integration_tests/management/network_map_db/user_test.go b/integration_tests/management/network_map_db/user_test.go
new file mode 100644
index 000000000..132f749e2
--- /dev/null
+++ b/integration_tests/management/network_map_db/user_test.go
@@ -0,0 +1,57 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetAllowedUsers(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-1','user-1','account-1','["group-one-resource-id"]',false,false)`)
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-2','user-2','account-1','["group-one-resource-id","group-two-resources-id"]',false,false)`)
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-3','user-3','account-1','["group-two-resources-id"]',false,false)`)
+	// shouldn't be included as it's blocked
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-4','user-4','account-1','["group-two-resources-id"]',true,false)`)
+	// shouldn't be included as it's a service_user
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-5','user-5','account-1','["group-two-resources-id"]',false,true)`)
+	execQuery(t, ctx,
+		`insert into groups (id, name, account_id)
+		VALUES('all-group-1','All','account-1')`)
+	execQuery(t, ctx,
+		`insert into groups (id, name, account_id)
+		VALUES('all-group-2','All','account-1')`)
+	execQuery(t, ctx,
+		`insert into groups (id, name, account_id)
+		VALUES('all-group-3','All','account-1')`)
+
+	userIdx, groupIdToUserIds, err := conn(t, ctx).GetAllowedUsers(ctx, "account-1")
+	assert.NoError(t, err)
+
+	assert.Equal(t, userIdx, map[string]struct{}{
+		"user-1": {},
+		"user-2": {},
+		"user-3": {},
+	})
+	assert.Equal(t, groupIdToUserIds, map[string][]string{
+		"group-one-resource-id":  {"user-1", "user-2"},
+		"group-two-resources-id": {"user-2", "user-3"},
+		"all-group-1":            {"user-1", "user-2", "user-3"},
+		"all-group-2":            {"user-1", "user-2", "user-3"},
+		"all-group-3":            {"user-1", "user-2", "user-3"},
+	})
+}
diff --git a/magefiles/magefile.go b/magefiles/magefile.go
new file mode 100644
index 000000000..34ab3c08f
--- /dev/null
+++ b/magefiles/magefile.go
@@ -0,0 +1,10 @@
+//mage:multiline
+
+// Set the general description you want to have displayed with mage -l here.
+package main
+
+// mg contains helpful utility functions, like Deps
+
+// Default target to run when none is specified
+// If not set, running mage will list available targets
+//var Default = Integrationtest.All
diff --git a/magefiles/test.go b/magefiles/test.go
new file mode 100644
index 000000000..2d08e8e01
--- /dev/null
+++ b/magefiles/test.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/magefile/mage/mg"
+	"github.com/magefile/mage/sh"
+)
+
+var defaultcli = []string{"test", "-tags=integration", "-timeout=20m"}
+
+type Integrationtest mg.Namespace
+
+func (i Integrationtest) All(gotestflags *string) error {
+	var errs []error
+	if err := i.Api(gotestflags); err != nil {
+		errs = append(errs, err)
+	}
+	if err := i.NmapDb(gotestflags); err != nil {
+		errs = append(errs, err)
+	}
+	if len(errs) > 0 {
+		return errors.Join(errs...)
+	}
+	return nil
+}
+
+func (Integrationtest) NmapDb(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./integration_tests/management/network_map_db/...")
+
+	return sh.RunV("go", cli...)
+}
+
+func (Integrationtest) NmapDbPostgres(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./integration_tests/management/network_map_db/...")
+
+	return sh.RunWithV(map[string]string{"NETBIRD_STORE_ENGINE": "postgres"}, "go", cli...)
+}
+
+func (Integrationtest) NmapDbSqlite(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./integration_tests/management/network_map_db/...")
+	return sh.RunWithV(map[string]string{"NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...)
+}
+
+func (Integrationtest) RegenerateNmapGoldenData(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./integration_tests/management/network_map_db/...")
+	return sh.RunWithV(map[string]string{"NMAP_UPDATE_GOLDEN_DATA": "true", "NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...)
+}
+
+func (Integrationtest) Api(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./management/server/http/...")
+	return sh.RunV("go", cli...)
+}
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 30de974a1..e74b17638 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -18,8 +18,10 @@ import (
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
 	"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
 	"github.com/netbirdio/netbird/management/internals/server/config"
 	"github.com/netbirdio/netbird/management/internals/shared/grpc"
+	"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
 	"github.com/netbirdio/netbird/management/server/account"
 	"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
 	"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
@@ -30,12 +32,16 @@ import (
 	"github.com/netbirdio/netbird/management/server/telemetry"
 	"github.com/netbirdio/netbird/management/server/types"
 	sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/management/status"
 	"github.com/netbirdio/netbird/util"
 	"github.com/netbirdio/netbird/version"
 )
 
+const defaultNetworkMapDataBufferInterval = 100 * time.Millisecond
+
 type Controller struct {
 	repo    Repository
 	metrics *metrics
@@ -61,6 +67,9 @@ type Controller struct {
 	serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion
 
 	perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion
+
+	nmdataStore  *networkmapdb.NetworkMapDBStoreImpl
+	nmdataBuffer *requestbuffer.Buffer[*networkmap.NetworkMapData]
 }
 
 type bufferUpdate struct {
@@ -78,13 +87,13 @@ type bufferAffectedUpdate struct {
 
 var _ network_map.Controller = (*Controller)(nil)
 
-func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config) *Controller {
+func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller {
 	nMetrics, err := newMetrics(metrics.UpdateChannelMetrics())
 	if err != nil {
 		log.Fatal(fmt.Errorf("error creating metrics: %w", err))
 	}
 
-	return &Controller{
+	c := &Controller{
 		repo:                    newRepository(store),
 		metrics:                 nMetrics,
 		accountManagerMetrics:   metrics.AccountManagerMetrics(),
@@ -99,7 +108,16 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App
 		EphemeralPeersManager:                        ephemeralPeersManager,
 		serverSupportedSyncMessageVersion:            sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion),
 		perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion),
+		nmdataStore:                                  nmdataStore,
 	}
+
+	if nmdataStore != nil {
+		interval := requestbuffer.Interval(ctx, "NB_NETWORK_MAP_DATA_BUFFER_INTERVAL", defaultNetworkMapDataBufferInterval)
+		log.WithContext(ctx).Infof("set network map data request buffer interval to %s", interval)
+		c.nmdataBuffer = requestbuffer.New(ctx, "network map data request buffer", interval, c.fetchNetworkMapData)
+	}
+
+	return c
 }
 
 func (c *Controller) OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *network_map.UpdateMessage, error) {
@@ -125,12 +143,12 @@ func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, p
 
 // injectAllProxyPolicies prepares an account for the per-peer network-map
 // computation. It prepends the in-memory agent-network services synthesised
-// from the account's current provider/policy state to account.Services so
-// the existing InjectProxyPolicies + injectPrivateServicePolicies walks pick
-// them up alongside persisted reverse-proxy services. Synthesised services
-// are never persisted; the account is loaded fresh per cycle so re-prepending
-// is safe and idempotent. Accounts without agent-network providers get an
-// empty synth slice — no behaviour change.
+// from the account's current provider/policy state to account.Services, so the
+// twin store built from the account carries them alongside the persisted
+// reverse-proxy services and synthesises their ACLs. Synthesised services are
+// never persisted; the account is loaded fresh per cycle so re-prepending is
+// safe and idempotent. Accounts without agent-network providers get an empty
+// synth slice — no behaviour change.
 func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) {
 	synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id)
 	if err != nil {
@@ -138,7 +156,26 @@ func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.
 	} else if len(synth) > 0 {
 		account.Services = append(synth, account.Services...)
 	}
-	account.InjectProxyPolicies(ctx)
+}
+
+// proxyServicesFromRepo is the store-path counterpart of
+// injectAllProxyPolicies: the network-map store reads the policies table, which
+// never holds the proxy ACLs, so the twin gets the services they are
+// synthesised from — the synthesised agent-network ones first, exactly as the
+// account path orders them.
+func (c *Controller) proxyServicesFromRepo(ctx context.Context, accountID string) []*nmdata.Service {
+	persisted, err := c.repo.GetAccountServices(ctx, accountID)
+	if err != nil {
+		log.WithContext(ctx).Errorf("failed to get services for account %s: %v", accountID, err)
+		return nil
+	}
+
+	synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, accountID)
+	if err != nil {
+		log.WithContext(ctx).Warnf("synthesise agent-network services for account %s: %v", accountID, err)
+	}
+
+	return types.TwinServices(append(synth, persisted...))
 }
 
 func (c *Controller) CountStreams() int {
@@ -147,6 +184,11 @@ func (c *Controller) CountStreams() int {
 
 func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
 	log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName())
+
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.sendUpdateAccountPeersFromData(ctx, accountID, reason, nmData)
+	}
+
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
 	if err != nil {
 		return fmt.Errorf("failed to get account: %v", err)
@@ -167,7 +209,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 		return nil
 	}
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return fmt.Errorf("failed to get validate peers: %v", err)
 	}
@@ -255,7 +297,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 				// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
 				// the client merges it into Calculate()'s output the same
 				// way the legacy server did via NetworkMap.Merge.
-				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
 
 				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -276,7 +318,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 			}
 
 			start = time.Now()
-			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 			c.metrics.CountToSyncResponseDuration(time.Since(start))
 
 			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -294,6 +336,290 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 	return nil
 }
 
+// sendUpdateAccountPeersFromData is the account-free variant of
+// sendUpdateAccountPeers: everything is computed from the network-map DB
+// store's twin data; only extra settings and validated peers are resolved at
+// runtime. Proxy network maps and policy injection, private-service zones,
+// group-to-user SSH mappings and forced routing-peer DNS resolution have no
+// DB-backed source yet and are omitted.
+func (c *Controller) sendUpdateAccountPeersFromData(ctx context.Context, accountID string, reason types.UpdateReason, nmData *networkmap.NetworkMapData) error {
+	peersToUpdate := c.connectedPeersFromData(nmData, nil)
+	if len(peersToUpdate) == 0 {
+		return nil
+	}
+	return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, &reason)
+}
+
+// sendUpdateForAffectedPeersFromData is the account-free variant of
+// sendUpdateForAffectedPeers.
+func (c *Controller) sendUpdateForAffectedPeersFromData(ctx context.Context, accountID string, peerIDs []string, nmData *networkmap.NetworkMapData) error {
+	if len(peerIDs) == 0 {
+		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no affected peers")
+		return nil
+	}
+
+	peersToUpdate := c.connectedPeersFromData(nmData, peerIDs)
+	if len(peersToUpdate) == 0 {
+		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no peers to update (affected peers not found in data or no channels)")
+		return nil
+	}
+
+	log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: sending network map to %d connected peers", len(peersToUpdate))
+
+	return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, nil)
+}
+
+// connectedPeersFromData returns the peers with an open update channel. An
+// empty affected list means all peers; a non-empty list restricts the result
+// to those peer IDs.
+func (c *Controller) connectedPeersFromData(nmData *networkmap.NetworkMapData, affected []string) []*nmdata.Peer {
+	if len(affected) == 0 {
+		result := make([]*nmdata.Peer, 0, len(nmData.Peers))
+		for _, peer := range nmData.Peers {
+			if c.peersUpdateManager.HasChannel(peer.ID) {
+				result = append(result, peer)
+			}
+		}
+		return result
+	}
+
+	result := make([]*nmdata.Peer, 0, len(affected))
+	for _, peerID := range affected {
+		peer := nmData.Peers[peerID]
+		if peer == nil {
+			continue
+		}
+		if c.peersUpdateManager.HasChannel(peerID) {
+			result = append(result, peer)
+		}
+	}
+	return result
+}
+
+func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, nmData *networkmap.NetworkMapData, peersToUpdate []*nmdata.Peer, reason *types.UpdateReason) error {
+	globalStart := time.Now()
+
+	extraSettings, err := c.settingsManager.GetExtraSettings(ctx, accountID)
+	if err != nil {
+		return fmt.Errorf("failed to get flow enabled status: %v", err)
+	}
+
+	dnsCache := &cache.DNSConfigCache{}
+	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
+	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
+
+	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	var wg sync.WaitGroup
+	semaphore := make(chan struct{}, 10)
+
+	for _, peer := range peersToUpdate {
+		if reason != nil && c.accountManagerMetrics != nil {
+			c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
+		}
+
+		wg.Add(1)
+		semaphore <- struct{}{}
+		go func(p *nmdata.Peer) {
+			defer wg.Done()
+			defer func() { <-semaphore }()
+
+			start := time.Now()
+
+			postureChecks := peerPostureChecksFromData(nmData, p.ID)
+
+			c.metrics.CountCalcPostureChecksDuration(time.Since(start))
+			start = time.Now()
+
+			peerGroups := maps.Keys(nmData.GetPeerGroups(p.ID))
+			var update *proto.SyncResponse
+
+			commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
+				c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
+				sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion))
+
+			log.WithContext(ctx).
+				WithFields(log.Fields{
+					"sync_message_version":        commonSyncMessageVersion,
+					"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
+					"peer_sync_message_version":   sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion),
+				}).Debug("common highest sync message version")
+
+			if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
+				components := nmData.GetPeerNetworkMapComponents(p.ID, peersCustomZone)
+
+				c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
+
+				start = time.Now()
+				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
+				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
+
+				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
+					Update:      update,
+					MessageType: network_map.MessageTypeNetworkMap,
+				})
+
+				return
+			}
+
+			nmap := NetworkMapFromData(ctx, nmData, p.ID, peersCustomZone, c.accountManagerMetrics)
+
+			c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
+
+			start = time.Now()
+			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
+			c.metrics.CountToSyncResponseDuration(time.Since(start))
+
+			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
+				Update:      update,
+				MessageType: network_map.MessageTypeNetworkMap,
+			})
+		}(peer)
+	}
+
+	wg.Wait()
+	if c.accountManagerMetrics != nil {
+		c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart))
+	}
+
+	return nil
+}
+
+func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *networkmap.NetworkMapData {
+	if c.nmdataBuffer == nil {
+		return nil
+	}
+
+	nmData, err := c.nmdataBuffer.Get(ctx, accountID)
+	if err != nil {
+		log.WithContext(ctx).Errorf("failed to get network map data for account %s, falling back to account-based computation: %v", accountID, err)
+		return nil
+	}
+
+	return nmData
+}
+
+// fetchNetworkMapData reads the twin once per buffer window. Its result is
+// shared by every waiter of that window, so the mutating steps run here, before
+// it is handed out: the twin the callers see is read-only. Injected proxy
+// policies carry no posture checks, so precomputing after the injection yields
+// the same validation as precomputing before it.
+func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) (*networkmap.NetworkMapData, error) {
+	nmData, err := c.nmdataStore.GetNetworkMapData(ctx, accountID)
+	if err != nil {
+		return nil, err
+	}
+
+	nmData.Services = c.proxyServicesFromRepo(ctx, accountID)
+	nmData.InjectProxyPolicies()
+	nmData.PrecomputePostureValidation()
+
+	return nmData, nil
+}
+
+func (c *Controller) getDNSDomainFromData(settings *nmdata.AccountSettingsInfo) string {
+	if settings == nil || settings.DNSDomain == "" {
+		return c.dnsDomain
+	}
+	return settings.DNSDomain
+}
+
+func IPv6AllowedPeersFromData(nmData *networkmap.NetworkMapData) map[string]struct{} {
+	result := make(map[string]struct{})
+	// An account with no IPv6-enabled group runs no overlay at all, so the
+	// embedded-proxy carve-out below has nothing to reach and stays shut.
+	if nmData.AccountSettings == nil || len(nmData.AccountSettings.IPv6EnabledGroups) == 0 {
+		return result
+	}
+	for _, groupID := range nmData.AccountSettings.IPv6EnabledGroups {
+		group := nmData.Groups[groupID]
+		if group == nil {
+			continue
+		}
+		for _, peerID := range group.Peers {
+			result[peerID] = struct{}{}
+		}
+	}
+	for id, p := range nmData.Peers {
+		if p != nil && p.ProxyMeta.Embedded {
+			result[id] = struct{}{}
+		}
+	}
+	return result
+}
+
+func NetworkMapFromData(ctx context.Context, nmData *networkmap.NetworkMapData, peerID string, peersCustomZone nmdata.CustomZone, metrics *telemetry.AccountManagerMetrics) *types.NetworkMap {
+	start := time.Now()
+
+	components := nmData.GetPeerNetworkMapComponents(peerID, peersCustomZone)
+	if components.IsEmpty() {
+		return &types.NetworkMap{Network: components.Network}
+	}
+	nm := types.CalculateNetworkMapFromComponents(ctx, components)
+
+	if metrics != nil {
+		objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
+		metrics.CountNetworkMapObjects(objectCount)
+		metrics.CountGetPeerNetworkMapDuration(time.Since(start))
+	}
+
+	return nm
+}
+
+// peerPostureChecksFromData mirrors getPeerPostureChecks on the twin store. The
+// sync response only encodes process-check file paths, so only ProcessCheck is
+// converted back to the server posture type.
+func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string) []*posture.Checks {
+	if len(nmData.PostureChecks) == 0 {
+		return nil
+	}
+
+	peerPostureChecks := make(map[string]*posture.Checks)
+	for _, policy := range nmData.Policies {
+		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
+			continue
+		}
+		if !isPeerInPolicySourceGroupsFromData(nmData, peerID, policy) {
+			continue
+		}
+		for _, checkID := range policy.SourcePostureChecks {
+			twin := nmData.PostureChecks[checkID]
+			if twin == nil {
+				continue
+			}
+			peerPostureChecks[checkID] = postureChecksFromTwin(twin)
+		}
+	}
+
+	return maps.Values(peerPostureChecks)
+}
+
+func isPeerInPolicySourceGroupsFromData(nmData *networkmap.NetworkMapData, peerID string, policy *nmdata.Policy) bool {
+	for _, rule := range policy.Rules {
+		if rule == nil || !rule.Enabled {
+			continue
+		}
+		for _, groupID := range rule.Sources {
+			if group := nmData.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func postureChecksFromTwin(twin *nmdata.PostureChecks) *posture.Checks {
+	checks := &posture.Checks{ID: twin.ID}
+	if twin.Checks.ProcessCheck != nil {
+		processes := make([]posture.Process, 0, len(twin.Checks.ProcessCheck.Processes))
+		for _, p := range twin.Checks.ProcessCheck.Processes {
+			processes = append(processes, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
+		}
+		checks.Checks.ProcessCheck = &posture.ProcessCheck{Processes: processes}
+	}
+	return checks
+}
+
 func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion {
 	if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok {
 		return perAccount
@@ -326,6 +652,10 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 		return nil
 	}
 
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.sendUpdateForAffectedPeersFromData(ctx, accountID, peerIDs, nmData)
+	}
+
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
 	if err != nil {
 		return fmt.Errorf("failed to get account: %v", err)
@@ -341,7 +671,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 
 	log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate))
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return fmt.Errorf("failed to get validate peers: %v", err)
 	}
@@ -428,7 +758,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 				// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
 				// the client merges it into Calculate()'s output the same
 				// way the legacy server did via NetworkMap.Merge.
-				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
 
 				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -449,7 +779,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 			}
 
 			start = time.Now()
-			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 			c.metrics.CountToSyncResponseDuration(time.Since(start))
 
 			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -506,7 +836,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
 		return fmt.Errorf("peer %s doesn't exists in account %s", peerId, accountId)
 	}
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return fmt.Errorf("failed to get validated peers: %v", err)
 	}
@@ -566,7 +896,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
 		// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
 		// the client merges it into Calculate()'s output the same
 		// way the legacy server did via NetworkMap.Merge.
-		update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
+		update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
 
 		c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
 			Update:      update,
@@ -583,7 +913,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
 		nmap.Merge(proxyNetworkMap)
 	}
 
-	update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
+	update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
 
 	c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
 		Update:      update,
@@ -643,7 +973,11 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
 		if err != nil {
 			return nil, nil, nil, nil, 0, err
 		}
-		return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil
+		return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
+	}
+
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.getValidatedPeerWithComponentsFromData(ctx, accountID, peer, nmData)
 	}
 
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
@@ -658,7 +992,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
 
 	c.injectAllProxyPolicies(ctx, account)
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return nil, nil, nil, nil, 0, err
 	}
@@ -695,6 +1029,21 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
 	return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil
 }
 
+// getValidatedPeerWithComponentsFromData is the account-free variant of
+// GetValidatedPeerWithComponents. The proxy network map fragment is omitted
+// like on the other nmdata paths.
+func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
+	postureChecks := peerPostureChecksFromData(nmData, peer.ID)
+
+	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
+	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
+
+	components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone)
+	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	return peer, components, nil, postureChecks, dnsFwdPort, nil
+}
+
 // BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
 func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
 	if len(peerIDs) == 0 {
@@ -801,11 +1150,15 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 		}
 
 		emptyMap := &types.NetworkMap{
-			Network: network.Copy(),
+			Network: types.TwinNetwork(network),
 		}
 		return emptyMap, nil, 0, nil
 	}
 
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.getValidatedPeerWithMapFromData(ctx, accountID, peerID, nmData)
+	}
+
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
 	if err != nil {
 		return nil, nil, 0, err
@@ -813,7 +1166,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 
 	c.injectAllProxyPolicies(ctx, account)
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return nil, nil, 0, err
 	}
@@ -853,6 +1206,21 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 	return networkMap, postureChecks, dnsFwdPort, nil
 }
 
+// getValidatedPeerWithMapFromData is the account-free variant of
+// GetValidatedPeerWithMap. The proxy network map fragment is omitted like on
+// the other nmdata paths.
+func (c *Controller) getValidatedPeerWithMapFromData(ctx context.Context, accountID string, peerID string, nmData *networkmap.NetworkMapData) (*types.NetworkMap, []*posture.Checks, int64, error) {
+	postureChecks := peerPostureChecksFromData(nmData, peerID)
+
+	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
+	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
+
+	networkMap := NetworkMapFromData(ctx, nmData, peerID, peersCustomZone, c.accountManagerMetrics)
+	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	return networkMap, postureChecks, dnsFwdPort, nil
+}
+
 // GetDNSDomain returns the configured dnsDomain
 func (c *Controller) GetDNSDomain(settings *types.Settings) string {
 	if settings == nil {
@@ -915,20 +1283,36 @@ func (c *Controller) StartWarmup(ctx context.Context) {
 // computeForwarderPort checks if all peers in the account have updated to a specific version or newer.
 // If all peers have the required version, it returns the new well-known port (22054), otherwise returns 0.
 func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
-	if len(peers) == 0 {
+	versions := make([]string, 0, len(peers))
+	for _, peer := range peers {
+		versions = append(versions, peer.Meta.WtVersion)
+	}
+	return computeForwarderPortFromVersions(versions, requiredVersion)
+}
+
+func ComputeForwarderPortFromData(peers map[string]*nmdata.Peer, requiredVersion string) int64 {
+	versions := make([]string, 0, len(peers))
+	for _, peer := range peers {
+		versions = append(versions, peer.Meta.WtVersion)
+	}
+	return computeForwarderPortFromVersions(versions, requiredVersion)
+}
+
+func computeForwarderPortFromVersions(wtVersions []string, requiredVersion string) int64 {
+	if len(wtVersions) == 0 {
 		return int64(network_map.OldForwarderPort)
 	}
 
 	reqVer := semver.Canonical(requiredVersion)
 
 	// Check if all peers have the required version or newer
-	for _, peer := range peers {
+	for _, wtVersion := range wtVersions {
 
 		// Development version is always supported
-		if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
+		if version.IsDevelopmentVersion(wtVersion) {
 			continue
 		}
-		peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
+		peerVersion := semver.Canonical("v" + wtVersion)
 		if peerVersion == "" {
 			// If any peer doesn't have version info, return 0
 			return int64(network_map.OldForwarderPort)
@@ -1062,7 +1446,12 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
 		groups[groupID] = group.Peers
 	}
 
-	validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	extraSettings, err := c.settingsManager.GetExtraSettings(ctx, account.Id)
+	if err != nil {
+		return nil, err
+	}
+
+	validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), extraSettings)
 	if err != nil {
 		return nil, err
 	}
diff --git a/management/internals/controllers/network_map/controller/ipv6_allowed_test.go b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go
new file mode 100644
index 000000000..c80f3b734
--- /dev/null
+++ b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go
@@ -0,0 +1,47 @@
+package controller
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// The account-side builder (types.Account.peerIPv6AllowedSet) is the reference:
+// an account with no IPv6-enabled group runs no IPv6 overlay at all, embedded
+// proxy peers included — see TestPeerIPv6AllowedEmbeddedProxy. Both builders
+// gate the same AAAA records, so the store-backed one has to agree.
+func TestIPv6AllowedPeersFromData(t *testing.T) {
+	data := func(enabledGroups []string) *networkmap.NetworkMapData {
+		return &networkmap.NetworkMapData{
+			AccountSettings: &nmdata.AccountSettingsInfo{IPv6EnabledGroups: enabledGroups},
+			Peers: map[string]*nmdata.Peer{
+				"peer1":  {ID: "peer1"},
+				"lonely": {ID: "lonely"},
+				"proxy":  {ID: "proxy", ProxyMeta: nmdata.ProxyMeta{Embedded: true, Cluster: "netbird.test"}},
+			},
+			Groups: map[string]*nmdata.Group{
+				"group-devs": {ID: "group-devs", Peers: []string{"peer1"}},
+			},
+		}
+	}
+
+	t.Run("embedded proxy allowed when any v6 group exists, without group membership", func(t *testing.T) {
+		allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
+		assert.Contains(t, allowed, "proxy", "embedded proxy participates in v6 overlay")
+		assert.Contains(t, allowed, "peer1", "regular peer in enabled group still allowed")
+	})
+
+	t.Run("embedded proxy denied when no v6 group enabled", func(t *testing.T) {
+		allowed := IPv6AllowedPeersFromData(data(nil))
+		assert.NotContains(t, allowed, "proxy", "v6 disabled account-wide denies embedded proxies too")
+		assert.Empty(t, allowed, "no peer participates in the v6 overlay")
+	})
+
+	t.Run("non-embedded peer outside any enabled group is not pulled in", func(t *testing.T) {
+		allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
+		assert.NotContains(t, allowed, "lonely", "embedded-proxy bypass must not leak to regular peers")
+	})
+}
diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go
index bd8ed4e80..5c3195f16 100644
--- a/management/internals/controllers/network_map/controller/repository.go
+++ b/management/internals/controllers/network_map/controller/repository.go
@@ -24,6 +24,7 @@ type Repository interface {
 	// services synthesised from the account's agent-network provider/policy
 	// state. Empty for accounts without agent-network providers.
 	SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error)
+	GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error)
 }
 
 type repository struct {
@@ -62,6 +63,10 @@ func (r *repository) SynthesizeAgentNetworkServices(ctx context.Context, account
 	return agentnetwork.SynthesizeServices(ctx, r.store, accountID)
 }
 
+func (r *repository) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) {
+	return r.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
+}
+
 func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
 	return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID)
 }
diff --git a/management/internals/controllers/network_map/nmaptest/canonicalize.go b/management/internals/controllers/network_map/nmaptest/canonicalize.go
new file mode 100644
index 000000000..ec6614d81
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/canonicalize.go
@@ -0,0 +1,380 @@
+package nmaptest
+
+import (
+	"bytes"
+	"cmp"
+	"fmt"
+	"slices"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// normalizeIDSpace replaces policy and route identifiers with positional
+// placeholders so a comparison can reach everything else.
+//
+// This exists only because the envelope round-trip currently substitutes each
+// internal xid with the object's public id, which is a tracked defect and not a
+// licence to differ: those identifiers reach the server again inside flow
+// events, which resolve them by internal id, so the substitution silently
+// breaks flow attribution for component-format peers. TestIDSpaceMatches
+// asserts the equality that must eventually hold; this erasure keeps the other
+// 40-odd cases reporting on semantics meanwhile. When the id space is unified,
+// delete this and the calls to it — every case should still pass.
+//
+// Cardinality and cross-references survive the erasure: two rules under one
+// policy still share a token and a route firewall rule still points at its
+// route, so a path that drops a policy, merges two policies, or misattributes a
+// rule to the wrong route still fails.
+func normalizeIDSpace(nm *proto.NetworkMap) {
+	if nm == nil {
+		return
+	}
+	policies := newTokenizer("policy")
+	routes := newTokenizer("route")
+
+	for _, i := range orderBy(nm.Routes, routeKeyWithoutID) {
+		nm.Routes[i].ID = routes.get(nm.Routes[i].ID)
+	}
+	for _, i := range orderBy(nm.FirewallRules, firewallKeyWithoutPolicy) {
+		r := nm.FirewallRules[i]
+		if len(r.PolicyID) > 0 {
+			r.PolicyID = []byte(policies.get(string(r.PolicyID)))
+		}
+	}
+	for _, i := range orderBy(nm.RoutesFirewallRules, routeFirewallKeyWithoutIDs) {
+		r := nm.RoutesFirewallRules[i]
+		if len(r.PolicyID) > 0 {
+			r.PolicyID = []byte(policies.get(string(r.PolicyID)))
+		}
+		r.RouteID = routes.get(r.RouteID)
+	}
+}
+
+// tokenizer maps identifiers to positional placeholders in order of first use.
+type tokenizer struct {
+	prefix string
+	seen   map[string]string
+}
+
+func newTokenizer(prefix string) *tokenizer {
+	return &tokenizer{prefix: prefix, seen: make(map[string]string)}
+}
+
+func (t *tokenizer) get(id string) string {
+	if id == "" {
+		return ""
+	}
+	if tok, ok := t.seen[id]; ok {
+		return tok
+	}
+	tok := fmt.Sprintf("%s#%d", t.prefix, len(t.seen))
+	t.seen[id] = tok
+	return tok
+}
+
+// orderBy returns indices sorted by key, so placeholder numbering does not
+// depend on the identifiers being erased.
+func orderBy[T any](items []T, key func(T) string) []int {
+	idx := make([]int, len(items))
+	for i := range idx {
+		idx[i] = i
+	}
+	sort.SliceStable(idx, func(a, b int) bool { return key(items[idx[a]]) < key(items[idx[b]]) })
+	return idx
+}
+
+func routeKeyWithoutID(r *proto.Route) string {
+	if r == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s|%s|%s|%d|%d|%t|%t|%v",
+		r.Network, r.NetID, r.Peer, r.Metric, r.NetworkType, r.Masquerade, r.KeepRoute, r.Domains)
+}
+
+func firewallKeyWithoutPolicy(r *proto.FirewallRule) string {
+	if r == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s|%d|%d|%d|%s|%s|%v",
+		r.PeerIP, r.Direction, r.Action, r.Protocol, r.Port, portInfoKey(r.PortInfo), r.SourcePrefixes) //nolint:staticcheck
+}
+
+func routeFirewallKeyWithoutIDs(r *proto.RouteFirewallRule) string {
+	if r == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s|%d|%d|%s|%v|%v|%t|%d",
+		r.Destination, r.Protocol, r.Action, portInfoKey(r.PortInfo), r.Domains, r.SourceRanges, r.IsDynamic, r.CustomProtocol)
+}
+
+// canonicalize sorts every repeated field of the NetworkMap by a stable key.
+// The producing paths iterate Go maps while building these slices, so order
+// can differ between runs even when the content is identical; comparing
+// without this reports noise.
+func canonicalize(nm *proto.NetworkMap) {
+	if nm == nil {
+		return
+	}
+	slices.SortFunc(nm.RemotePeers, cmpRemotePeer)
+	slices.SortFunc(nm.OfflinePeers, cmpRemotePeer)
+	slices.SortFunc(nm.Routes, cmpRoute)
+	slices.SortFunc(nm.FirewallRules, cmpFirewallRule)
+	slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule)
+	slices.SortFunc(nm.ForwardingRules, cmpForwardingRule)
+
+	for _, r := range nm.FirewallRules {
+		slices.SortFunc(r.SourcePrefixes, bytes.Compare)
+	}
+	for _, r := range nm.RoutesFirewallRules {
+		slices.Sort(r.SourceRanges)
+	}
+	canonicalizeDNSConfig(nm.DNSConfig)
+	canonicalizeSSHAuth(nm.SshAuth)
+}
+
+func canonicalizeDNSConfig(d *proto.DNSConfig) {
+	if d == nil {
+		return
+	}
+	for _, g := range d.NameServerGroups {
+		if g == nil {
+			continue
+		}
+		slices.Sort(g.Domains)
+		slices.SortFunc(g.NameServers, func(a, b *proto.NameServer) int {
+			if a == nil || b == nil {
+				return boolCmp(a == nil, b == nil)
+			}
+			if c := cmp.Compare(a.IP, b.IP); c != 0 {
+				return c
+			}
+			if c := cmp.Compare(a.Port, b.Port); c != 0 {
+				return c
+			}
+			return cmp.Compare(a.NSType, b.NSType)
+		})
+	}
+	slices.SortFunc(d.NameServerGroups, func(a, b *proto.NameServerGroup) int {
+		return cmp.Compare(nsgKey(a), nsgKey(b))
+	})
+	for _, z := range d.CustomZones {
+		if z == nil {
+			continue
+		}
+		slices.SortFunc(z.Records, cmpSimpleRecord)
+	}
+	slices.SortFunc(d.CustomZones, func(a, b *proto.CustomZone) int {
+		if a == nil || b == nil {
+			return boolCmp(a == nil, b == nil)
+		}
+		return cmp.Compare(a.Domain, b.Domain)
+	})
+}
+
+// canonicalizeSSHAuth sorts AuthorizedUsers and re-keys MachineUsers.Indexes
+// against the new ordering, preserving which machine user maps to which hashes.
+func canonicalizeSSHAuth(s *proto.SSHAuth) {
+	if s == nil || len(s.AuthorizedUsers) == 0 {
+		return
+	}
+	type hashed struct {
+		bytes []byte
+		old   uint32
+	}
+	entries := make([]hashed, len(s.AuthorizedUsers))
+	for i, b := range s.AuthorizedUsers {
+		entries[i] = hashed{bytes: b, old: uint32(i)}
+	}
+	slices.SortFunc(entries, func(a, b hashed) int { return bytes.Compare(a.bytes, b.bytes) })
+
+	remap := make(map[uint32]uint32, len(entries))
+	sorted := make([][]byte, len(entries))
+	for newIdx, e := range entries {
+		remap[e.old] = uint32(newIdx)
+		sorted[newIdx] = e.bytes
+	}
+	s.AuthorizedUsers = sorted
+
+	for _, mu := range s.MachineUsers {
+		if mu == nil {
+			continue
+		}
+		for i, oldIdx := range mu.Indexes {
+			if newIdx, ok := remap[oldIdx]; ok {
+				mu.Indexes[i] = newIdx
+			}
+		}
+		slices.Sort(mu.Indexes)
+	}
+}
+
+func boolCmp(a, b bool) int {
+	if a == b {
+		return 0
+	}
+	if a {
+		return 1
+	}
+	return -1
+}
+
+func nsgKey(g *proto.NameServerGroup) string {
+	if g == nil {
+		return ""
+	}
+	var parts []string
+	for _, ns := range g.NameServers {
+		if ns == nil {
+			continue
+		}
+		parts = append(parts, ns.IP+":"+strconv.FormatInt(ns.Port, 10)+":"+strconv.FormatInt(ns.NSType, 10))
+	}
+	slices.Sort(parts)
+	key := strings.Join(parts, ",")
+	domains := append([]string(nil), g.Domains...)
+	slices.Sort(domains)
+	key += "|" + strings.Join(domains, "|")
+	if g.Primary {
+		key += "|P"
+	}
+	if g.SearchDomainsEnabled {
+		key += "|S"
+	}
+	return key
+}
+
+func cmpSimpleRecord(a, b *proto.SimpleRecord) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(a.Name, b.Name); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Type, b.Type); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Class, b.Class); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.RData, b.RData); c != 0 {
+		return c
+	}
+	return cmp.Compare(a.TTL, b.TTL)
+}
+
+func cmpRemotePeer(a, b *proto.RemotePeerConfig) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	return cmp.Compare(a.WgPubKey, b.WgPubKey)
+}
+
+func cmpRoute(a, b *proto.Route) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(a.ID, b.ID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.NetID, b.NetID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Network, b.Network); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Peer, b.Peer); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Metric, b.Metric); c != 0 {
+		return c
+	}
+	return slices.Compare(a.Domains, b.Domains)
+}
+
+func cmpFirewallRule(a, b *proto.FirewallRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.PeerIP, b.PeerIP); c != 0 { //nolint:staticcheck
+		return c
+	}
+	if c := cmp.Compare(int32(a.Direction), int32(b.Direction)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Port, b.Port); c != 0 {
+		return c
+	}
+	return cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo))
+}
+
+func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.RouteID, b.RouteID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Destination, b.Destination); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
+		return c
+	}
+	if c := slices.Compare(a.Domains, b.Domains); c != 0 {
+		return c
+	}
+	if c := slices.Compare(a.SourceRanges, b.SourceRanges); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.CustomProtocol, b.CustomProtocol); c != 0 {
+		return c
+	}
+	return boolCmp(a.IsDynamic, b.IsDynamic)
+}
+
+func cmpForwardingRule(a, b *proto.ForwardingRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress)
+}
+
+func portInfoKey(pi *proto.PortInfo) string {
+	if pi == nil {
+		return ""
+	}
+	switch sel := pi.PortSelection.(type) {
+	case *proto.PortInfo_Port:
+		return "P" + strconv.FormatUint(uint64(sel.Port), 10)
+	case *proto.PortInfo_Range_:
+		if sel.Range == nil {
+			return "R"
+		}
+		return "R" + strconv.FormatUint(uint64(sel.Range.Start), 10) + "-" + strconv.FormatUint(uint64(sel.Range.End), 10)
+	}
+	return ""
+}
diff --git a/management/internals/controllers/network_map/nmaptest/fixture.go b/management/internals/controllers/network_map/nmaptest/fixture.go
new file mode 100644
index 000000000..d56285f95
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/fixture.go
@@ -0,0 +1,218 @@
+package nmaptest
+
+import (
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// LoadNetworkMapData reads a fixture holding the NetworkMapData the store
+// would return for one account. Unknown fields are rejected so fixture typos
+// fail loudly instead of silently testing a default.
+func LoadNetworkMapData(path string) (*networkmap.NetworkMapData, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("open fixture: %w", err)
+	}
+	defer f.Close()
+
+	dec := json.NewDecoder(f)
+	dec.DisallowUnknownFields()
+	var nmData networkmap.NetworkMapData
+	if err := dec.Decode(&nmData); err != nil {
+		return nil, fmt.Errorf("decode fixture %s: %w", path, err)
+	}
+	return &nmData, nil
+}
+
+var defaultNetworkNet = func() net.IPNet {
+	_, ipnet, err := net.ParseCIDR("100.64.0.0/10")
+	if err != nil {
+		panic(err)
+	}
+	return *ipnet
+}()
+
+// applyFixtureDefaults fills the boilerplate a fixture may omit. Map-keyed
+// objects inherit their key as ID, peers get a deterministic WG-shaped key
+// and their ID as DNS label, PublicIDs default to the internal ID (the
+// envelope encoder puts public IDs on the wire and silently degrades on
+// empty ones), and a nil ValidatedPeers validates every peer — production
+// fills it through the integrated validator, not the store.
+func applyFixtureDefaults(nmData *networkmap.NetworkMapData) {
+	if nmData.Network == nil {
+		nmData.Network = &nmdata.Network{}
+	}
+	if nmData.Network.Identifier == "" {
+		nmData.Network.Identifier = "network"
+	}
+	if nmData.Network.Net.IP == nil {
+		nmData.Network.Net = defaultNetworkNet
+	}
+	if nmData.AccountSettings == nil {
+		nmData.AccountSettings = &nmdata.AccountSettingsInfo{}
+	}
+	if nmData.DNSSettings == nil {
+		nmData.DNSSettings = &nmdata.DNSSettings{}
+	}
+
+	for id, p := range nmData.Peers {
+		if p == nil {
+			continue
+		}
+		if p.ID == "" {
+			p.ID = id
+		}
+		if p.Key == "" {
+			p.Key = derivedWgKey(p.ID)
+		}
+		if p.DNSLabel == "" {
+			p.DNSLabel = p.ID
+		}
+	}
+
+	for id, g := range nmData.Groups {
+		if g == nil {
+			continue
+		}
+		if g.ID == "" {
+			g.ID = id
+		}
+		if g.Name == "" {
+			g.Name = g.ID
+		}
+		if g.PublicID == "" {
+			g.PublicID = g.ID
+		}
+	}
+
+	for _, policy := range nmData.Policies {
+		defaultPolicyIDs(policy)
+	}
+	resolveResourcePolicyRefs(nmData)
+
+	for _, r := range nmData.Routes {
+		if r != nil && r.PublicID == "" {
+			r.PublicID = r.ID
+		}
+	}
+	for _, nsg := range nmData.NameServerGroups {
+		if nsg != nil && nsg.PublicID == "" {
+			nsg.PublicID = nsg.ID
+		}
+	}
+	for _, res := range nmData.NetworkResources {
+		if res == nil {
+			continue
+		}
+		if res.PublicID == "" {
+			res.PublicID = res.ID
+		}
+		defaultXIDMapping(&nmData.NetworkXIDToPublicID, res.NetworkID)
+	}
+	for networkID, routers := range nmData.Routers {
+		defaultXIDMapping(&nmData.NetworkXIDToPublicID, networkID)
+		for _, router := range routers {
+			if router != nil && router.PublicID == "" {
+				router.PublicID = networkID
+			}
+		}
+	}
+
+	for id, pc := range nmData.PostureChecks {
+		if pc == nil {
+			continue
+		}
+		if pc.ID == "" {
+			pc.ID = id
+		}
+		defaultXIDMapping(&nmData.PostureCheckXIDToPublicID, pc.ID)
+	}
+
+	if nmData.ValidatedPeers == nil {
+		nmData.ValidatedPeers = make(map[string]struct{}, len(nmData.Peers))
+		for id := range nmData.Peers {
+			nmData.ValidatedPeers[id] = struct{}{}
+		}
+	}
+}
+
+// resolveResourcePolicyRefs lets a fixture name an account policy by ID in
+// ResourcePolicies — {"ID": "pol-x"} with no rules — instead of repeating it.
+// The real store puts the same policy pointer in both places, which is what
+// resolving the reference reproduces.
+func resolveResourcePolicyRefs(nmData *networkmap.NetworkMapData) {
+	byID := make(map[string]*nmdata.Policy, len(nmData.Policies))
+	for _, policy := range nmData.Policies {
+		if policy != nil && policy.ID != "" {
+			byID[policy.ID] = policy
+		}
+	}
+
+	for _, policies := range nmData.ResourcePolicies {
+		for i, policy := range policies {
+			if policy == nil {
+				continue
+			}
+			if len(policy.Rules) == 0 {
+				if full, ok := byID[policy.ID]; ok {
+					policies[i] = full
+					continue
+				}
+			}
+			defaultPolicyIDs(policy)
+		}
+	}
+}
+
+func defaultPolicyIDs(policy *nmdata.Policy) {
+	if policy == nil {
+		return
+	}
+	if policy.PublicID == "" {
+		policy.PublicID = policy.ID
+	}
+	for i, rule := range policy.Rules {
+		if rule == nil {
+			continue
+		}
+		if rule.PolicyID == "" {
+			rule.PolicyID = policy.ID
+		}
+		if rule.ID == "" {
+			// Production gives a rule its policy's id (management/server/policy.go:205,
+			// "when policy can contain multiple rules, need refactor"), so a
+			// single-rule policy — the only shape the product can create today —
+			// must be modelled that way or the wire ids come out unrealistic.
+			rule.ID = policy.ID
+			if len(policy.Rules) > 1 {
+				rule.ID = fmt.Sprintf("%s-rule-%d", policy.ID, i)
+			}
+		}
+	}
+}
+
+func defaultXIDMapping(m *map[string]string, id string) {
+	if id == "" {
+		return
+	}
+	if *m == nil {
+		*m = make(map[string]string)
+	}
+	if _, ok := (*m)[id]; !ok {
+		(*m)[id] = id
+	}
+}
+
+// derivedWgKey returns a deterministic base64 key of 32 bytes, valid for the
+// envelope decoder's WG-key identity.
+func derivedWgKey(peerID string) string {
+	sum := sha256.Sum256([]byte(peerID))
+	return base64.StdEncoding.EncodeToString(sum[:])
+}
diff --git a/management/internals/controllers/network_map/nmaptest/golden_test.go b/management/internals/controllers/network_map/nmaptest/golden_test.go
new file mode 100644
index 000000000..75c0d57d2
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/golden_test.go
@@ -0,0 +1,12 @@
+package nmaptest_test
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/nmaptest"
+)
+
+func TestNetworkMapGolden(t *testing.T) {
+	nmaptest.RunGoldenDir(t, filepath.Join("testdata", "cases"))
+}
diff --git a/management/internals/controllers/network_map/nmaptest/legacyaccount.go b/management/internals/controllers/network_map/nmaptest/legacyaccount.go
new file mode 100644
index 000000000..d6a653f7a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/legacyaccount.go
@@ -0,0 +1,543 @@
+package nmaptest
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/miekg/dns"
+	"github.com/stretchr/testify/require"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
+	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/posture"
+	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/management/server/types/legacynmap"
+	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/proto"
+	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+// legacyInput is the account and the four derived arguments main's computation
+// took alongside it. The controller resolved them from the account before
+// calling; the twin carries them as fields, so the fixture is the source for
+// both halves.
+type legacyInput struct {
+	account          *types.Account
+	accountZones     []*zones.Zone
+	validatedPeers   map[string]struct{}
+	resourcePolicies map[string][]*types.Policy
+	routers          map[string]map[string]*routerTypes.NetworkRouter
+	groupIDToUserIDs map[string][]string
+}
+
+// legacyInputFromData rebuilds the Account the fixture stands for. A fixture is
+// the value the store returns, and the store's twins carry exactly the state
+// the computation reads, so inverting them reproduces the account main would
+// have loaded — which is what lets one expectation measure all three paths.
+//
+// The inverse is only defined for what a twin carries: fields the builders drop
+// (peer names, policy descriptions, user records behind AllowedUserIDs) come
+// back as the zero value or a minimal stand-in, because no path reads them.
+func legacyInputFromData(accountID string, nmData *networkmap.NetworkMapData) legacyInput {
+	account := &types.Account{
+		Id:               accountID,
+		Network:          accountNetwork(nmData.Network),
+		Settings:         accountSettings(nmData.AccountSettings),
+		DNSSettings:      types.DNSSettings{DisabledManagementGroups: nmData.DNSSettings.DisabledManagementGroups},
+		Peers:            make(map[string]*nbpeer.Peer, len(nmData.Peers)),
+		Groups:           make(map[string]*types.Group, len(nmData.Groups)),
+		Policies:         make([]*types.Policy, 0, len(nmData.Policies)),
+		Routes:           make(map[nbroute.ID]*nbroute.Route, len(nmData.Routes)),
+		NameServerGroups: make(map[string]*nbdns.NameServerGroup, len(nmData.NameServerGroups)),
+		NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(nmData.NetworkResources)),
+		PostureChecks:    make([]*posture.Checks, 0, len(nmData.PostureChecks)),
+		Users:            make(map[string]*types.User, len(nmData.AllowedUserIDs)),
+		Services:         accountServices(nmData.Services),
+	}
+
+	for id, p := range nmData.Peers {
+		account.Peers[id] = accountPeer(id, p)
+	}
+	for id, g := range nmData.Groups {
+		account.Groups[id] = accountGroup(id, g)
+	}
+
+	policiesByID := make(map[string]*types.Policy, len(nmData.Policies))
+	for _, p := range nmData.Policies {
+		policy := accountPolicy(p)
+		if policy == nil {
+			continue
+		}
+		account.Policies = append(account.Policies, policy)
+		policiesByID[policy.ID] = policy
+	}
+
+	for _, r := range nmData.Routes {
+		route := accountRoute(r)
+		if route != nil {
+			account.Routes[route.ID] = route
+		}
+	}
+	for _, nsg := range nmData.NameServerGroups {
+		group := accountNSG(nsg)
+		if group != nil {
+			account.NameServerGroups[group.ID] = group
+		}
+	}
+	for _, res := range nmData.NetworkResources {
+		if resource := accountNetworkResource(res); resource != nil {
+			account.NetworkResources = append(account.NetworkResources, resource)
+		}
+	}
+	for id, pc := range nmData.PostureChecks {
+		if check := accountPostureChecks(id, pc, nmData.PostureCheckXIDToPublicID[id]); check != nil {
+			account.PostureChecks = append(account.PostureChecks, check)
+		}
+	}
+	for xid, publicID := range nmData.NetworkXIDToPublicID {
+		account.Networks = append(account.Networks, &networkTypes.Network{ID: xid, PublicID: publicID})
+	}
+	// The twin keeps only the ids of the users a peer may be shared with; the
+	// legacy side derives the same set from the account's user records, so a
+	// bare non-blocked regular user per id is enough.
+	for userID := range nmData.AllowedUserIDs {
+		account.Users[userID] = &types.User{Id: userID}
+	}
+
+	// Main's network-map controller synthesised the reverse-proxy ACLs onto the
+	// account and only then derived the resource-policy map, so the frozen copy
+	// has to be fed in that order to stand for what main produced.
+	account.Policies = append(account.Policies, legacynmap.SynthesizeProxyPolicies(account)...)
+
+	return legacyInput{
+		account:          account,
+		accountZones:     accountZones(nmData.AppliedZoneCandidates),
+		validatedPeers:   nmData.ValidatedPeers,
+		resourcePolicies: account.GetResourcePoliciesMap(),
+		routers:          accountRouters(nmData.Routers),
+		groupIDToUserIDs: nmData.GroupIDToUserIDs,
+	}
+}
+
+// computeLegacy runs the fixture through main's frozen path and its own proto
+// encoder, the one comparison surface the three modes share.
+func computeLegacy(t *testing.T, ctx context.Context, legacy legacyInput, peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64) *proto.NetworkMap {
+	t.Helper()
+
+	require.NotNil(t, legacy.account, "legacy mode needs an account rebuilt from the fixture")
+	peer := legacy.account.Peers[peerID]
+	require.NotNil(t, peer, "target peer %q not in rebuilt account", peerID)
+
+	nm := legacynmap.GetPeerNetworkMapFromComponents(
+		legacy.account, ctx, peerID, legacyCustomZone(zone), legacy.accountZones, legacy.validatedPeers,
+		legacy.resourcePolicies, legacy.routers, nil, legacy.groupIDToUserIDs,
+	)
+	require.NotNil(t, nm, "legacy path returned no network map for peer %q", peerID)
+
+	return legacynmap.ToProtoNetworkMap(
+		ctx, peer, nm, dnsDomain, legacy.account.Settings, nil, &cache.DNSConfigCache{}, dnsFwdPort,
+	)
+}
+
+// legacyCustomZone converts the peers custom zone the runner computes once for
+// every mode into the shape main's path took.
+func legacyCustomZone(z nmdata.CustomZone) nbdns.CustomZone {
+	zoneRecords := make([]nbdns.SimpleRecord, 0, len(z.Records))
+	for _, r := range z.Records {
+		zoneRecords = append(zoneRecords, nbdns.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		})
+	}
+	return nbdns.CustomZone{
+		Domain:               z.Domain,
+		Records:              zoneRecords,
+		SearchDomainDisabled: z.SearchDomainDisabled,
+		NonAuthoritative:     z.NonAuthoritative,
+	}
+}
+
+func accountNetwork(n *nmdata.Network) *types.Network {
+	if n == nil {
+		return nil
+	}
+	return &types.Network{
+		Identifier: n.Identifier,
+		Net:        n.Net,
+		NetV6:      n.NetV6,
+		Dns:        n.Dns,
+		Serial:     uint64(n.Serial),
+	}
+}
+
+func accountSettings(s *nmdata.AccountSettingsInfo) *types.Settings {
+	if s == nil {
+		return nil
+	}
+	return &types.Settings{
+		PeerLoginExpirationEnabled:      s.PeerLoginExpirationEnabled,
+		PeerLoginExpiration:             s.PeerLoginExpiration,
+		PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled,
+		PeerInactivityExpiration:        s.PeerInactivityExpiration,
+		DNSDomain:                       s.DNSDomain,
+		IPv6EnabledGroups:               s.IPv6EnabledGroups,
+		RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled,
+		LazyConnectionEnabled:           s.LazyConnectionEnabled,
+		AutoUpdateVersion:               s.AutoUpdateVersion,
+		AutoUpdateAlways:                s.AutoUpdateAlways,
+		MetricsPushEnabled:              s.MetricsPushEnabled,
+	}
+}
+
+func accountPeer(id string, p *nmdata.Peer) *nbpeer.Peer {
+	if p == nil {
+		return nil
+	}
+	networkAddresses := make([]nbpeer.NetworkAddress, 0, len(p.Meta.NetworkAddresses))
+	for _, na := range p.Meta.NetworkAddresses {
+		networkAddresses = append(networkAddresses, nbpeer.NetworkAddress{NetIP: na.NetIP})
+	}
+	files := make([]nbpeer.File, 0, len(p.Meta.Files))
+	for _, f := range p.Meta.Files {
+		files = append(files, nbpeer.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning})
+	}
+	return &nbpeer.Peer{
+		ID:                     id,
+		Key:                    p.Key,
+		SSHKey:                 p.SSHKey,
+		DNSLabel:               p.DNSLabel,
+		UserID:                 p.UserID,
+		SSHEnabled:             p.SSHEnabled,
+		LoginExpirationEnabled: p.LoginExpirationEnabled,
+		LastLogin:              p.LastLogin,
+		IP:                     p.IP,
+		IPv6:                   p.IPv6,
+		ExtraDNSLabels:         p.ExtraDNSLabels,
+		ProxyMeta:              nbpeer.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
+		// Connected is what SynthesizePrivateServiceZones gates its records on,
+		// and a fixture peer stands for a peer the store returned, so it is one
+		// the account would have reported connected.
+		Status: &nbpeer.PeerStatus{RequiresApproval: p.RequiresApproval, Connected: true},
+		Meta: nbpeer.PeerSystemMeta{
+			WtVersion:          p.Meta.WtVersion,
+			GoOS:               p.Meta.GoOS,
+			OSVersion:          p.Meta.OSVersion,
+			KernelVersion:      p.Meta.KernelVersion,
+			NetworkAddresses:   networkAddresses,
+			Files:              files,
+			Capabilities:       p.Meta.Capabilities,
+			SyncMessageVersion: p.Meta.SyncMessageVersion,
+			Flags: nbpeer.Flags{
+				ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
+				DisableIPv6:      p.Meta.Flags.DisableIPv6,
+			},
+		},
+		Location: nbpeer.Location{
+			CountryCode:  p.Location.CountryCode,
+			CityName:     p.Location.CityName,
+			ConnectionIP: p.Location.ConnectionIP,
+		},
+	}
+}
+
+func accountGroup(id string, g *nmdata.Group) *types.Group {
+	if g == nil {
+		return nil
+	}
+	return &types.Group{
+		ID:       id,
+		Name:     g.Name,
+		PublicID: g.PublicID,
+		Peers:    g.Peers,
+	}
+}
+
+func accountPolicy(p *nmdata.Policy) *types.Policy {
+	if p == nil {
+		return nil
+	}
+	rules := make([]*types.PolicyRule, 0, len(p.Rules))
+	for _, r := range p.Rules {
+		if r == nil {
+			continue
+		}
+		var portRanges []sharedtypes.RulePortRange
+		if r.PortRanges != nil {
+			portRanges = make([]sharedtypes.RulePortRange, len(r.PortRanges))
+			for i, pr := range r.PortRanges {
+				portRanges[i] = sharedtypes.RulePortRange{Start: pr.Start, End: pr.End}
+			}
+		}
+		rules = append(rules, &types.PolicyRule{
+			ID:                  r.ID,
+			PolicyID:            r.PolicyID,
+			Enabled:             r.Enabled,
+			Action:              sharedtypes.PolicyTrafficActionType(r.Action),
+			Protocol:            sharedtypes.PolicyRuleProtocolType(r.Protocol),
+			Bidirectional:       r.Bidirectional,
+			Sources:             r.Sources,
+			Destinations:        r.Destinations,
+			SourceResource:      types.Resource{ID: r.SourceResource.ID, Type: sharedtypes.ResourceType(r.SourceResource.Type)},
+			DestinationResource: types.Resource{ID: r.DestinationResource.ID, Type: sharedtypes.ResourceType(r.DestinationResource.Type)},
+			Ports:               r.Ports,
+			PortRanges:          portRanges,
+			AuthorizedGroups:    r.AuthorizedGroups,
+			AuthorizedUser:      r.AuthorizedUser,
+		})
+	}
+	return &types.Policy{
+		ID:                  p.ID,
+		PublicID:            p.PublicID,
+		Enabled:             p.Enabled,
+		SourcePostureChecks: p.SourcePostureChecks,
+		Rules:               rules,
+	}
+}
+
+func accountRoute(r *nmdata.Route) *nbroute.Route {
+	if r == nil {
+		return nil
+	}
+	return &nbroute.Route{
+		ID:                  nbroute.ID(r.ID),
+		AccountID:           r.AccountID,
+		PublicID:            r.PublicID,
+		Network:             r.Network,
+		Domains:             r.Domains,
+		KeepRoute:           r.KeepRoute,
+		NetID:               nbroute.NetID(r.NetID),
+		Description:         r.Description,
+		Peer:                r.Peer,
+		PeerID:              r.PeerID,
+		PeerGroups:          r.PeerGroups,
+		NetworkType:         nbroute.NetworkType(r.NetworkType),
+		Masquerade:          r.Masquerade,
+		Metric:              r.Metric,
+		Enabled:             r.Enabled,
+		Groups:              r.Groups,
+		AccessControlGroups: r.AccessControlGroups,
+		SkipAutoApply:       r.SkipAutoApply,
+	}
+}
+
+func accountNSG(n *nmdata.NameServerGroup) *nbdns.NameServerGroup {
+	if n == nil {
+		return nil
+	}
+	nameServers := make([]nbdns.NameServer, 0, len(n.NameServers))
+	for _, ns := range n.NameServers {
+		nameServers = append(nameServers, nbdns.NameServer{
+			IP:     ns.IP,
+			NSType: nbdns.NameServerType(ns.NSType),
+			Port:   ns.Port,
+		})
+	}
+	return &nbdns.NameServerGroup{
+		ID:                   n.ID,
+		PublicID:             n.PublicID,
+		Name:                 n.Name,
+		Description:          n.Description,
+		NameServers:          nameServers,
+		Groups:               n.Groups,
+		Primary:              n.Primary,
+		Domains:              n.Domains,
+		Enabled:              n.Enabled,
+		SearchDomainsEnabled: n.SearchDomainsEnabled,
+	}
+}
+
+func accountNetworkResource(r *nmdata.NetworkResource) *resourceTypes.NetworkResource {
+	if r == nil {
+		return nil
+	}
+	return &resourceTypes.NetworkResource{
+		ID:          r.ID,
+		NetworkID:   r.NetworkID,
+		AccountID:   r.AccountID,
+		PublicID:    r.PublicID,
+		Name:        r.Name,
+		Description: r.Description,
+		Type:        resourceTypes.NetworkResourceType(r.Type),
+		Address:     r.Address,
+		Domain:      r.Domain,
+		Prefix:      r.Prefix,
+		Enabled:     r.Enabled,
+	}
+}
+
+func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string) *posture.Checks {
+	if pc == nil {
+		return nil
+	}
+	out := &posture.Checks{ID: id, PublicID: publicID}
+	def := pc.Checks
+	if def.NBVersionCheck != nil {
+		out.Checks.NBVersionCheck = &posture.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion}
+	}
+	if def.OSVersionCheck != nil {
+		oc := &posture.OSVersionCheck{}
+		if def.OSVersionCheck.Android != nil {
+			oc.Android = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion}
+		}
+		if def.OSVersionCheck.Darwin != nil {
+			oc.Darwin = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion}
+		}
+		if def.OSVersionCheck.Ios != nil {
+			oc.Ios = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion}
+		}
+		if def.OSVersionCheck.Linux != nil {
+			oc.Linux = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion}
+		}
+		if def.OSVersionCheck.Windows != nil {
+			oc.Windows = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion}
+		}
+		out.Checks.OSVersionCheck = oc
+	}
+	if def.GeoLocationCheck != nil {
+		gc := &posture.GeoLocationCheck{Action: def.GeoLocationCheck.Action}
+		for _, loc := range def.GeoLocationCheck.Locations {
+			gc.Locations = append(gc.Locations, posture.Location{CountryCode: loc.CountryCode, CityName: loc.CityName})
+		}
+		out.Checks.GeoLocationCheck = gc
+	}
+	if def.PeerNetworkRangeCheck != nil {
+		out.Checks.PeerNetworkRangeCheck = &posture.PeerNetworkRangeCheck{
+			Action: def.PeerNetworkRangeCheck.Action,
+			Ranges: def.PeerNetworkRangeCheck.Ranges,
+		}
+	}
+	if def.ProcessCheck != nil {
+		procs := make([]posture.Process, 0, len(def.ProcessCheck.Processes))
+		for _, p := range def.ProcessCheck.Processes {
+			procs = append(procs, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
+		}
+		out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs}
+	}
+	return out
+}
+
+func accountServices(services []*nmdata.Service) []*service.Service {
+	if len(services) == 0 {
+		return nil
+	}
+	out := make([]*service.Service, 0, len(services))
+	for _, svc := range services {
+		if svc == nil {
+			continue
+		}
+		targets := make([]*service.Target, 0, len(svc.Targets))
+		for _, t := range svc.Targets {
+			if t == nil {
+				continue
+			}
+			target := &service.Target{
+				Enabled:    t.Enabled,
+				Port:       t.Port,
+				Protocol:   t.Protocol,
+				TargetId:   t.TargetID,
+				TargetType: service.TargetType(t.TargetType),
+			}
+			if t.Path != "" {
+				path := t.Path
+				target.Path = &path
+			}
+			targets = append(targets, target)
+		}
+		out = append(out, &service.Service{
+			ID:           svc.ID,
+			Enabled:      svc.Enabled,
+			Private:      svc.Private,
+			Mode:         svc.Mode,
+			ProxyCluster: svc.ProxyCluster,
+			AccessGroups: svc.AccessGroups,
+			Targets:      targets,
+		})
+	}
+	return out
+}
+
+// accountZones inverts buildAppliedZoneCandidates. Records come back with the
+// record type the builder mapped them from; a candidate only ever carries the
+// three types it converts.
+func accountZones(candidates []networkmap.AppliedZoneCandidate) []*zones.Zone {
+	if len(candidates) == 0 {
+		return nil
+	}
+	out := make([]*zones.Zone, 0, len(candidates))
+	for _, candidate := range candidates {
+		zoneRecords := make([]*records.Record, 0, len(candidate.Zone.Records))
+		for _, r := range candidate.Zone.Records {
+			recordType, ok := zoneRecordType(r.Type)
+			if !ok {
+				continue
+			}
+			zoneRecords = append(zoneRecords, &records.Record{
+				Name:    strings.TrimSuffix(r.Name, "."),
+				Type:    recordType,
+				Content: r.RData,
+				TTL:     r.TTL,
+			})
+		}
+		out = append(out, &zones.Zone{
+			ID:                 candidate.Zone.Domain,
+			Domain:             strings.TrimSuffix(candidate.Zone.Domain, "."),
+			Enabled:            true,
+			EnableSearchDomain: !candidate.Zone.SearchDomainDisabled,
+			DistributionGroups: candidate.DistributionGroups,
+			Records:            zoneRecords,
+		})
+	}
+	return out
+}
+
+func zoneRecordType(recordType int) (records.RecordType, bool) {
+	switch uint16(recordType) {
+	case dns.TypeA:
+		return records.RecordTypeA, true
+	case dns.TypeAAAA:
+		return records.RecordTypeAAAA, true
+	case dns.TypeCNAME:
+		return records.RecordTypeCNAME, true
+	default:
+		return "", false
+	}
+}
+
+func accountRouters(routers map[string]map[string]*nmdata.NetworkRouter) map[string]map[string]*routerTypes.NetworkRouter {
+	if len(routers) == 0 {
+		return nil
+	}
+	out := make(map[string]map[string]*routerTypes.NetworkRouter, len(routers))
+	for networkID, inner := range routers {
+		converted := make(map[string]*routerTypes.NetworkRouter, len(inner))
+		for peerID, router := range inner {
+			if router == nil {
+				continue
+			}
+			converted[peerID] = &routerTypes.NetworkRouter{
+				NetworkID:  networkID,
+				PublicID:   router.PublicID,
+				Peer:       peerID,
+				PeerGroups: router.PeerGroups,
+				Masquerade: router.Masquerade,
+				Metric:     router.Metric,
+				Enabled:    router.Enabled,
+			}
+		}
+		out[networkID] = converted
+	}
+	return out
+}
diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go
new file mode 100644
index 000000000..c70bd7298
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/runner.go
@@ -0,0 +1,332 @@
+// Package nmaptest measures network map generation on the dedicated store
+// path against committed expectations. A case stands in for the store load
+// with a NetworkMapData fixture — the value NetworkMapDBStoreImpl returns for
+// one account — then runs the production per-peer pipeline the controller
+// uses, PeersCustomZone → GetPeerNetworkMapComponents → proto conversion, in
+// both wire shapes: the full map (grpc.ToSyncResponse) and the component
+// envelope expanded client-side (grpc.ToComponentSyncResponse →
+// networkmap.EnvelopeToNetworkMap). A third mode inverts the fixture back into
+// the Account it stands for and runs main's frozen path over it (legacynmap),
+// so every case is pinned to what main shipped as well.
+//
+// The expectation files are the point of the framework. They state what the
+// output should be, so a failing case means the code disagrees with the
+// expectation and the answer is normally to fix the code; an expectation
+// changes only through a deliberate reviewed edit. Nothing in this package
+// writes to testdata — there is no flag that records current behaviour into an
+// expectation, because that is how a defect becomes the baseline. Cases whose
+// expectation encodes correct behaviour the code does not yet deliver stay red
+// on purpose.
+//
+// A case lives in testdata/cases// as case.json (manifest: description,
+// peers, optional accountID, dnsDomain, modes), nmdata.json (the fixture the
+// mocked store returns, using Go field names; zero values may be omitted and
+// applyFixtureDefaults fills the boilerplate) and golden/.json.
+//
+// There is ONE expectation per peer, shared by every mode, because all three
+// must arrive at the same client-facing map. Full and envelope are not even
+// different computations — CalculateNetworkMapFromComponents is
+// components.Calculate and both assemble the proto with the same encode
+// helpers — so the only variable between them is what the envelope round-trip
+// did in transit, and a difference there is a round-trip fidelity defect.
+// Legacy is a different computation, main's, reached from a rebuilt account;
+// a difference there is this tree having drifted from what main shipped.
+// Results are canonicalized before comparison, since repeated proto fields
+// come from map iteration.
+package nmaptest
+
+import (
+	"bytes"
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/exp/maps"
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/testing/protocmp"
+
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+	mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// Mode selects the wire shape a case is verified through. Both end in a
+// *proto.NetworkMap, the one comparison surface shared by every path.
+type Mode string
+
+const (
+	// ModeFull is the legacy wire shape: the server runs Calculate and sends
+	// the expanded map (grpc.ToSyncResponse).
+	ModeFull Mode = "full"
+	// ModeEnvelope is the component wire shape: the server encodes components
+	// into a NetworkMapEnvelope (grpc.ToComponentSyncResponse) and the map is
+	// expanded the way the client engine does (networkmap.EnvelopeToNetworkMap).
+	ModeEnvelope Mode = "envelope"
+	// ModeLegacy is main's frozen path: the fixture is inverted back into the
+	// Account it stands for and run through legacynmap, the copy of what main
+	// shipped. It is the outside measurement — the other two modes share this
+	// tree's computation, so only this one can catch the whole tree drifting.
+	ModeLegacy Mode = "legacy"
+
+	defaultAccountID = "account"
+	defaultDNSDomain = "netbird.test"
+)
+
+var defaultModes = []Mode{ModeFull, ModeEnvelope, ModeLegacy}
+
+// Case is one nmap-generation scenario: store data for a single account, the
+// peers whose network maps are computed, and the directory holding one expected
+// *proto.NetworkMap per peer — shared by every mode.
+type Case struct {
+	Name      string
+	AccountID string
+	DNSDomain string
+	Peers     []string
+	Modes     []Mode
+	Data      *networkmap.NetworkMapData
+	GoldenDir string
+}
+
+type manifest struct {
+	Description string
+	AccountID   string
+	DNSDomain   string
+	Peers       []string
+	Modes       []Mode
+}
+
+// RunGoldenDir discovers and runs every fixture case under dir. A case is a
+// directory containing case.json (manifest), nmdata.json (store fixture) and
+// golden/.json (expected proto.NetworkMap, protojson).
+func RunGoldenDir(t *testing.T, dir string) {
+	t.Helper()
+
+	entries, err := os.ReadDir(dir)
+	require.NoError(t, err, "read cases dir")
+
+	ran := 0
+	for _, entry := range entries {
+		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
+			continue
+		}
+		caseDir := filepath.Join(dir, entry.Name())
+		c, err := loadCase(caseDir)
+		require.NoError(t, err, "load case %s", entry.Name())
+		ran++
+		t.Run(entry.Name(), func(t *testing.T) {
+			RunCase(t, c)
+		})
+	}
+	require.NotZero(t, ran, "no cases found under %s", dir)
+}
+
+func loadCase(caseDir string) (Case, error) {
+	raw, err := os.ReadFile(filepath.Join(caseDir, "case.json"))
+	if err != nil {
+		return Case{}, fmt.Errorf("read manifest: %w", err)
+	}
+	dec := json.NewDecoder(bytes.NewReader(raw))
+	dec.DisallowUnknownFields()
+	var m manifest
+	if err := dec.Decode(&m); err != nil {
+		return Case{}, fmt.Errorf("decode manifest: %w", err)
+	}
+
+	data, err := LoadNetworkMapData(filepath.Join(caseDir, "nmdata.json"))
+	if err != nil {
+		return Case{}, err
+	}
+
+	return Case{
+		Name:      filepath.Base(caseDir),
+		AccountID: m.AccountID,
+		DNSDomain: m.DNSDomain,
+		Peers:     m.Peers,
+		Modes:     m.Modes,
+		Data:      data,
+		GoldenDir: filepath.Join(caseDir, "golden"),
+	}, nil
+}
+
+// RunCase computes each target peer's network map through every enabled mode
+// and compares the canonicalized result against the peer's expectation file.
+// It mirrors the controller's store path: fill fixture defaults, precompute
+// posture validation once, then run the per-peer pipeline.
+func RunCase(t *testing.T, c Case) {
+	t.Helper()
+
+	require.NotNil(t, c.Data, "case %s: Data is required", c.Name)
+	require.NotEmpty(t, c.Peers, "case %s: Peers is required", c.Name)
+	require.NotEmpty(t, c.GoldenDir, "case %s: GoldenDir is required", c.Name)
+	if c.AccountID == "" {
+		c.AccountID = defaultAccountID
+	}
+	if c.DNSDomain == "" {
+		c.DNSDomain = defaultDNSDomain
+	}
+	if len(c.Modes) == 0 {
+		c.Modes = defaultModes
+	}
+
+	ctx := context.Background()
+	nmData := c.Data
+	applyFixtureDefaults(nmData)
+	nmData.PrecomputePostureValidation()
+
+	dnsDomain := c.DNSDomain
+	if nmData.AccountSettings.DNSDomain != "" {
+		dnsDomain = nmData.AccountSettings.DNSDomain
+	}
+
+	zone := networkmap.PeersCustomZone(ctx, c.AccountID, dnsDomain, nmData.Peers, controller.IPv6AllowedPeersFromData(nmData))
+	dnsFwdPort := controller.ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	for _, mode := range c.Modes {
+		if mode == ModeEnvelope {
+			requireEnvelopeSafeKeys(t, nmData, c.Name)
+			break
+		}
+	}
+
+	// Built before any mode runs: the first per-peer computation injects the
+	// synthesised proxy ACLs into the twin's policies, and the legacy side
+	// synthesises its own, so inverting a twin that already carries them would
+	// hand the legacy path each ACL twice.
+	var legacy legacyInput
+	if slices.Contains(c.Modes, ModeLegacy) {
+		legacy = legacyInputFromData(c.AccountID, nmData)
+	}
+
+	for _, peerID := range c.Peers {
+		peer := nmData.Peers[peerID]
+		require.NotNil(t, peer, "case %s: target peer %q not in fixture", c.Name, peerID)
+
+		for _, mode := range c.Modes {
+			t.Run(peerID+"/"+string(mode), func(t *testing.T) {
+				got := computeMode(t, ctx, mode, nmData, peerID, zone, dnsDomain, dnsFwdPort, legacy)
+				canonicalize(got)
+				compareGolden(t, filepath.Join(c.GoldenDir, peerID+".json"), got, mode)
+			})
+		}
+	}
+}
+
+// computeMode produces the peer's proto.NetworkMap the way the controller does
+// for that wire shape.
+func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkmap.NetworkMapData,
+	peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64, legacy legacyInput) *proto.NetworkMap {
+	t.Helper()
+
+	peer := nmData.Peers[peerID]
+	require.NotNil(t, peer, "target peer %q not in fixture", peerID)
+
+	switch mode {
+	case ModeLegacy:
+		return computeLegacy(t, ctx, legacy, peerID, zone, dnsDomain, dnsFwdPort)
+	case ModeFull:
+		nmap := controller.NetworkMapFromData(ctx, nmData, peerID, zone, nil)
+		return mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, nmap, dnsDomain, nil,
+			&cache.DNSConfigCache{}, nmData.AccountSettings, nil, nil, dnsFwdPort).NetworkMap
+	case ModeEnvelope:
+		components := nmData.GetPeerNetworkMapComponents(peerID, zone)
+		peerGroups := maps.Keys(nmData.GetPeerGroups(peerID))
+		resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil,
+			dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort)
+		res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain)
+		require.NoError(t, err, "expand envelope")
+		return res.NetworkMap
+	default:
+		t.Fatalf("unknown mode %q", mode)
+		return nil
+	}
+}
+
+// requireEnvelopeSafeKeys fails fast on peer keys the envelope decoder would
+// silently drop: it re-keys peers by base64 of the raw 32-byte WG public key.
+func requireEnvelopeSafeKeys(t *testing.T, nmData *networkmap.NetworkMapData, caseName string) {
+	t.Helper()
+	for id, p := range nmData.Peers {
+		if p == nil {
+			continue
+		}
+		raw, err := base64.StdEncoding.DecodeString(p.Key)
+		if err != nil || len(raw) != 32 {
+			t.Fatalf("case %s: peer %q Key must be base64 of 32 bytes for mode %q (the envelope decoder drops it otherwise); use a real WireGuard public key or restrict the case to mode %q",
+				caseName, id, ModeEnvelope, ModeFull)
+		}
+	}
+}
+
+// compareGolden measures got against the committed expectation file. One
+// expectation serves every mode, because the modes run the same computation and
+// must therefore agree. The expectation is the authority: a mismatch means the
+// code does not produce what this case says it should, so it is reported as a
+// failure and not quietly absorbed.
+//
+// The full and legacy modes are compared verbatim, identifiers included, so the
+// expectation pins real ids and stays readable. The envelope mode has
+// identifiers erased on both sides first, because it currently rewrites them —
+// a tracked defect that TestIDSpaceMatches asserts against on its own, so it
+// does not have to drown out every other case here.
+// Nothing here writes to testdata. Expectation files are authored by hand and
+// only ever change through a reviewed edit, so there is no mode in which a run
+// can create or replace one. When a file is missing the computed map is printed
+// for the author to read and, if it is genuinely correct, save deliberately.
+func compareGolden(t *testing.T, path string, got *proto.NetworkMap, mode Mode) {
+	t.Helper()
+
+	if mode == ModeEnvelope {
+		normalizeIDSpace(got)
+		canonicalize(got)
+	}
+
+	raw, err := os.ReadFile(path)
+	if err != nil {
+		rendered, mErr := renderNetworkMap(got)
+		require.NoError(t, mErr)
+		t.Fatalf("no expectation file %s: %v\nThis case has nothing to measure against — write the "+
+			"proto.NetworkMap this peer should receive. Mode %s currently produces:\n%s\nRead it before "+
+			"saving any of it: if the code is wrong, so is this.", path, err, mode, rendered)
+	}
+	want := &proto.NetworkMap{}
+	require.NoError(t, protojson.Unmarshal(raw, want), "parse expectation %s", path)
+	canonicalize(want)
+	if mode == ModeEnvelope {
+		normalizeIDSpace(want)
+		canonicalize(want)
+	}
+
+	if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
+		t.Errorf("mode %s does not produce what %s expects (-want +got):\n%s\n"+
+			"Every mode has to deliver the same client-facing map for the same account state. "+
+			"The expectation file is the committed statement of correct output — fix the code, or change the "+
+			"expectation deliberately if the intended behaviour really moved.", mode, path, diff)
+	}
+}
+
+// renderNetworkMap renders stable protojson: protojson output whitespace is
+// deliberately unstable, so it is reformatted through json.Indent.
+func renderNetworkMap(nm *proto.NetworkMap) ([]byte, error) {
+	raw, err := protojson.Marshal(nm)
+	if err != nil {
+		return nil, err
+	}
+	var buf bytes.Buffer
+	if err := json.Indent(&buf, raw, "", "  "); err != nil {
+		return nil, err
+	}
+	buf.WriteByte('\n')
+	return buf.Bytes(), nil
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json
new file mode 100644
index 000000000..4747e9640
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Two groups joined by one allow-all policy; peer-c has SSH enabled so the legacy-SSH path fills SshAuth from AllowedUserIDs.",
+  "peers": [
+    "peer-a",
+    "peer-c"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json
new file mode 100644
index 000000000..e2b69276c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json
@@ -0,0 +1,65 @@
+{
+  "Serial": "5",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "4deEImv8zGvsyBmmfC2G0eQkbyMzyGuz/YK7pcYETwM=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {
+        "sshPubKey": "c3NoLXBlZXItYw=="
+      },
+      "fqdn": "peer-c.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json
new file mode 100644
index 000000000..4c358b163
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json
@@ -0,0 +1,102 @@
+{
+  "Serial": "5",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-c.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "u9dHvAXZJKiXITuwP9jD/A=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          0
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json
new file mode 100644
index 000000000..7d78e5c61
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json
@@ -0,0 +1,31 @@
+{
+  "Network": {"Serial": 5},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "SSHEnabled": true, "SSHKey": "ssh-peer-c", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-all",
+      "PublicID": "pol-all-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    }
+  ],
+  "AllowedUserIDs": {"user-ops": {}}
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json
new file mode 100644
index 000000000..e4c46c63d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Nameserver group and applied custom zones distributed to grp-dev; peer-a (with an extra DNS label) receives them, peer-c is outside that group and receives only the zone distributed to grp-ops. Zone flags travel per zone: both grp-dev zones are match-only (NonAuthoritative), only search-off.internal. disables the search domain, and the built-in peer zone stays authoritative.",
+  "peers": [
+    "peer-a",
+    "peer-c"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json
new file mode 100644
index 000000000..f06a19d9d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json
@@ -0,0 +1,115 @@
+{
+  "Serial": "8",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "NameServerGroups": [
+      {
+        "NameServers": [
+          {
+            "IP": "8.8.8.8",
+            "Port": "53"
+          }
+        ],
+        "Primary": true
+      }
+    ],
+    "CustomZones": [
+      {
+        "Domain": "corp.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {
+            "Name": "db.corp.internal.",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "10.10.0.5"
+          }
+        ]
+      },
+      {
+        "Domain": "search-off.internal.",
+        "SearchDomainDisabled": true,
+        "NonAuthoritative": true,
+        "Records": [
+          {
+            "Name": "alias.search-off.internal.",
+            "Type": "5",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "app.search-off.internal."
+          },
+          {
+            "Name": "app.search-off.internal.",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "10.10.0.6"
+          }
+        ]
+      },
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "www.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLW1lc2g="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLW1lc2g="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json
new file mode 100644
index 000000000..7e04dca40
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json
@@ -0,0 +1,47 @@
+{
+  "Serial": "8",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      },
+      {
+        "Domain": "ops-only.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {
+            "Name": "tool.ops-only.internal.",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "10.10.0.7"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json
new file mode 100644
index 000000000..b9741ef16
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json
@@ -0,0 +1,74 @@
+{
+  "Network": {"Serial": 8},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "ExtraDNSLabels": ["www"], "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-mesh",
+      "PublicID": "pol-mesh-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-dev"]
+        }
+      ]
+    }
+  ],
+  "NameServerGroups": [
+    {
+      "ID": "nsg-1",
+      "Name": "dns-primary",
+      "NameServers": [{"IP": "8.8.8.8", "Port": 53}],
+      "Groups": ["grp-dev"],
+      "Primary": true,
+      "Enabled": true
+    }
+  ],
+  "AppliedZoneCandidates": [
+    {
+      "DistributionGroups": ["grp-dev"],
+      "Zone": {
+        "Domain": "corp.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {"Name": "db.corp.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.5"}
+        ]
+      }
+    },
+    {
+      "DistributionGroups": ["grp-dev"],
+      "Zone": {
+        "Domain": "search-off.internal.",
+        "NonAuthoritative": true,
+        "SearchDomainDisabled": true,
+        "Records": [
+          {"Name": "app.search-off.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.6"},
+          {"Name": "alias.search-off.internal.", "Type": 5, "Class": "IN", "TTL": 300, "RData": "app.search-off.internal."}
+        ]
+      }
+    },
+    {
+      "DistributionGroups": ["grp-ops"],
+      "Zone": {
+        "Domain": "ops-only.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {"Name": "tool.ops-only.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.7"}
+        ]
+      }
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json
new file mode 100644
index 000000000..a5776d0e7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Domain network resource: the route carries the domain list and the 192.0.2.0/32 placeholder network with NetworkType 3 (dynamic), and peer-r's route firewall rules must be marked dynamic and repeat the domain. Two ports on the policy must produce one rule per port. A domain resource contributes no DNS custom zone of its own — resolution happens through the routing peer's forwarder.",
+  "peers": ["peer-a", "peer-r"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json
new file mode 100644
index 000000000..f83e7a2f2
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json
@@ -0,0 +1,59 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:peer-r",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json
new file mode 100644
index 000000000..41ae3dd33
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json
@@ -0,0 +1,92 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:peer-r",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "192.0.2.0/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 443
+      },
+      "isDynamic": true,
+      "domains": [
+        "app.internal"
+      ],
+      "PolicyID": "cG9sLWFwcA==",
+      "RouteID": "res-domain:peer-r"
+    },
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "192.0.2.0/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 80
+      },
+      "isDynamic": true,
+      "domains": [
+        "app.internal"
+      ],
+      "PolicyID": "cG9sLWFwcA==",
+      "RouteID": "res-domain:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json
new file mode 100644
index 000000000..db6dc8eda
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 22},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-app",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["80", "443"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-domain", "Type": "domain"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-domain": [{"ID": "pol-app"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-domain",
+      "NetworkID": "net-1",
+      "Name": "app-domain",
+      "Type": "domain",
+      "Domain": "app.internal",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json
new file mode 100644
index 000000000..fa1e5c24b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Host network resource (single /32) behind one directly-assigned router. peer-a is in the resource policy's source group and must receive one route to 10.10.0.7/32 via peer-r with KeepRoute set and NetID taken from the resource name; peer-r as the router must receive the same route plus a route firewall rule whose SourceRanges are the policy's source peers. A client never gets route firewall rules.",
+  "peers": ["peer-a", "peer-r"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json
new file mode 100644
index 000000000..8bf83f20e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json
@@ -0,0 +1,56 @@
+{
+  "Serial": "20",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-host:peer-r",
+      "Network": "10.10.0.7/32",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "web-host",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json
new file mode 100644
index 000000000..ef3b5a6c8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "20",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-host:peer-r",
+      "Network": "10.10.0.7/32",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "web-host",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.10.0.7/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 443
+      },
+      "PolicyID": "cG9sLXdlYg==",
+      "RouteID": "res-host:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json
new file mode 100644
index 000000000..fdd35a439
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 20},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-web",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-host", "Type": "host"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-host": [{"ID": "pol-web"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-host",
+      "NetworkID": "net-1",
+      "Name": "web-host",
+      "Type": "host",
+      "Prefix": "10.10.0.7/32",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json
new file mode 100644
index 000000000..ca54a4b81
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "A disabled resource with a valid policy and router must leave no trace: no routes and no route firewall rules for either the client or the router. Disabling a resource is the switch that revokes access without deleting the policy.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json
new file mode 100644
index 000000000..a4f5a92bb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "25",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json
new file mode 100644
index 000000000..b83cfdff6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "25",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json
new file mode 100644
index 000000000..43e00a2db
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 25},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-off-resource",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-disabled", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-disabled": [{"ID": "pol-off-resource"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-disabled",
+      "NetworkID": "net-1",
+      "Name": "disabled-subnet",
+      "Type": "subnet",
+      "Prefix": "10.50.0.0/24"
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json
new file mode 100644
index 000000000..494ac0fce
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "An enabled resource with a healthy router but no policy granting access to it must produce nothing anywhere: no route for the client and none for the router either, since access to a resource is only ever created by a policy. The router also gets no route firewall rules despite being a routing peer for the network.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json
new file mode 100644
index 000000000..32f9cf35e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "24",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json
new file mode 100644
index 000000000..a97eac9a3
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "24",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json
new file mode 100644
index 000000000..a3bbf299a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json
@@ -0,0 +1,26 @@
+{
+  "Network": {"Serial": 24},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "NetworkResources": [
+    {
+      "ID": "res-orphan",
+      "NetworkID": "net-1",
+      "Name": "orphan-subnet",
+      "Type": "subnet",
+      "Prefix": "10.40.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json
new file mode 100644
index 000000000..2922a5deb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "A DISABLED policy granting access to a network resource must grant nothing: no route to 10.90.0.0/24 for peer-a and none for the router either, exactly as if the policy were absent. THE FULL EXPECTATION CURRENTLY FAILS, and should: resource-policy selection never checks policy.Enabled (networkmapcompute.go and networkmap_components.go both test only nil/len(Rules)/Rules[0]), so the legacy path still hands out the route — access survives disabling the policy. The envelope path happens to be correct because the encoder drops disabled policies from the wire. Fix the compute path, do not weaken this expectation.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json
new file mode 100644
index 000000000..92ba75b1e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "39",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json
new file mode 100644
index 000000000..8c27320ee
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "39",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json
new file mode 100644
index 000000000..be7251712
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 39},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-revoked",
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-db": [{"ID": "pol-revoked"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "NetworkID": "net-1",
+      "Name": "db-subnet",
+      "Type": "subnet",
+      "Prefix": "10.90.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json
new file mode 100644
index 000000000..de1027d48
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "The routing peer for the resource is not in ValidatedPeers — an unapproved peer, which the integrated validator withholds. peer-a must therefore receive no route through it and must not see it as a peer at all: traffic may not be routed through a peer the account has not approved. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: component selection puts every routing peer into RouterPeers without checking validation, the encoder indexes them into the envelope's peer table, and the client decoder puts every peer it finds back into its peer map, so the unapproved router reappears client-side with a working route. The full path drops it correctly. Fix the component/encoder path, do not weaken this expectation.",
+  "peers": ["peer-a"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json
new file mode 100644
index 000000000..2554fc08c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "40",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json
new file mode 100644
index 000000000..98ba94471
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json
@@ -0,0 +1,44 @@
+{
+  "Network": {"Serial": 40},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "ValidatedPeers": {"peer-a": {}},
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-db",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-db": [{"ID": "pol-db"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "NetworkID": "net-1",
+      "Name": "db-subnet",
+      "Type": "subnet",
+      "Prefix": "10.100.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json
new file mode 100644
index 000000000..8ec63c816
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Routing peer group: one router record assigned to a peer group, which the store expands into one entry per member peer sharing the router's settings. peer-a must receive one route per routing peer — same NetID and destination, different route ID and peer — which is what gives the client an HA pair to choose between. Each router must receive only its own route, never its sibling's, plus its own route firewall rule.",
+  "peers": ["peer-a", "peer-r1", "peer-r2"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json
new file mode 100644
index 000000000..020c34835
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json
@@ -0,0 +1,75 @@
+{
+  "Serial": "23",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "allowedIps": [
+        "100.64.0.11/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r1.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r2.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-ha:peer-r1",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    },
+    {
+      "ID": "res-ha:peer-r2",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json
new file mode 100644
index 000000000..e42214ca8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "23",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-r1.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-ha:peer-r1",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r1.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.30.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLWhh",
+      "RouteID": "res-ha:peer-r1"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json
new file mode 100644
index 000000000..2560742fb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "23",
+  "peerConfig": {
+    "address": "100.64.0.12/10",
+    "sshConfig": {},
+    "fqdn": "peer-r2.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-ha:peer-r2",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r2.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.30.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLWhh",
+      "RouteID": "res-ha:peer-r2"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json
new file mode 100644
index 000000000..03937cb14
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json
@@ -0,0 +1,46 @@
+{
+  "Network": {"Serial": 23},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-routers": {"Peers": ["peer-r1", "peer-r2"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-ha",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-ha", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-ha": [{"ID": "pol-ha"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-ha",
+      "NetworkID": "net-ha",
+      "Name": "ha-subnet",
+      "Type": "subnet",
+      "Prefix": "10.30.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-ha": {
+      "peer-r1": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true},
+      "peer-r2": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json
new file mode 100644
index 000000000..16cfedf34
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Subnet network resource behind one directly-assigned router, with masquerade off and a non-default metric so both reach the wire verbatim, and an all-protocol policy from a two-peer source group. peer-r's route firewall rule must list both source peers; peer-b confirms a second client in the same group gets its own identical route.",
+  "peers": ["peer-a", "peer-b", "peer-r"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json
new file mode 100644
index 000000000..a5bc8f880
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json
@@ -0,0 +1,55 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-subnet:peer-r",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "500",
+      "NetID": "office-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json
new file mode 100644
index 000000000..01c31edf6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json
@@ -0,0 +1,55 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-subnet:peer-r",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "500",
+      "NetID": "office-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json
new file mode 100644
index 000000000..39a29125d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json
@@ -0,0 +1,76 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-subnet:peer-r",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "500",
+      "NetID": "office-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32",
+        "100.64.0.2/32"
+      ],
+      "destination": "10.20.0.0/24",
+      "protocol": "ALL",
+      "portInfo": {},
+      "PolicyID": "cG9sLXN1Ym5ldA==",
+      "RouteID": "res-subnet:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json
new file mode 100644
index 000000000..ba27f494c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 21},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-subnet",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-subnet", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-subnet": [{"ID": "pol-subnet"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-subnet",
+      "NetworkID": "net-1",
+      "Name": "office-subnet",
+      "Type": "subnet",
+      "Prefix": "10.20.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Metric": 500, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json
new file mode 100644
index 000000000..39c477b9f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Direct peer-to-peer policy via Source/DestinationResource of type peer, no groups involved; peer-a and peer-b see each other, bystander peer-c sees nobody.",
+  "peers": ["peer-a", "peer-b", "peer-c"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json
new file mode 100644
index 000000000..4d59c33bb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "15",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json
new file mode 100644
index 000000000..59b4bd24c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "15",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json
new file mode 100644
index 000000000..9ff24ce1a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "15",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json
new file mode 100644
index 000000000..f3ee4d163
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json
@@ -0,0 +1,26 @@
+{
+  "Network": {"Serial": 15},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Policies": [
+    {
+      "ID": "pol-direct",
+      "PublicID": "pol-direct-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-a", "Type": "peer"},
+          "DestinationResource": {"ID": "peer-b", "Type": "peer"}
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json
new file mode 100644
index 000000000..a0eccba20
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "One-way udp/514 plus bidirectional tcp port-range 1000-2000 between the same groups; a disabled policy and a policy whose only rule is disabled must leave no trace.",
+  "peers": ["peer-a", "peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json
new file mode 100644
index 000000000..5a2276d96
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json
@@ -0,0 +1,81 @@
+{
+  "Serial": "14",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "UDP",
+      "Port": "514",
+      "PolicyID": "cG9sLXN5c2xvZw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json
new file mode 100644
index 000000000..6a89148f5
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json
@@ -0,0 +1,80 @@
+{
+  "Serial": "14",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "UDP",
+      "Port": "514",
+      "PolicyID": "cG9sLXN5c2xvZw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json
new file mode 100644
index 000000000..f1262a0d7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json
@@ -0,0 +1,74 @@
+{
+  "Network": {"Serial": 14},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-svc": {"Peers": ["peer-srv"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-syslog",
+      "PublicID": "pol-syslog-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "udp",
+          "Ports": ["514"],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-range",
+      "PublicID": "pol-range-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "PortRanges": [{"Start": 1000, "End": 2000}],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-off",
+      "PublicID": "pol-off-pub",
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["9999"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-rule-off",
+      "PublicID": "pol-rule-off-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Action": "accept",
+          "Protocol": "udp",
+          "Ports": ["1111"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json
new file mode 100644
index 000000000..3307e0afe
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Posture checks gate a policy's sources only, never its destinations. peer-srv-old would fail the version check, but it sits in the destination group, so peer-client must still receive it alongside peer-srv-new, and peer-srv-old must still receive peer-client. This asymmetry is deliberate in the compute path — destination peers are resolved with no posture checks passed in — and it is worth pinning because it is easy to assume a posture check protects both ends.",
+  "peers": ["peer-client", "peer-srv-old"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json
new file mode 100644
index 000000000..73182932b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "37",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-client.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MdeD+cDSnurizeZ/Zd7rEdIhs9VZViEnutUwkodqb1s=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv-new.netbird.test",
+      "agentVersion": "1.0.0"
+    },
+    {
+      "wgPubKey": "ph1eqUTlSeLQ6V9zLEUpck25m5K5sOQq+AHY879HZME=",
+      "allowedIps": [
+        "100.64.0.11/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv-old.netbird.test",
+      "agentVersion": "0.30.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-client.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv-new.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          },
+          {
+            "Name": "peer-srv-old.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.11",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.11",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json
new file mode 100644
index 000000000..8d1ad4feb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "37",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv-old.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "tKxuKEYQFPR8lCpcfVWBKVX0vGFKYXtTtFjXhoiu5zc=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-client.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-client.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv-old.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json
new file mode 100644
index 000000000..ca5ece21a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json
@@ -0,0 +1,33 @@
+{
+  "Network": {"Serial": 37},
+  "Peers": {
+    "peer-client": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}},
+    "peer-srv-old": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.30.0"}},
+    "peer-srv-new": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-client"]},
+    "grp-srv": {"Peers": ["peer-srv-old", "peer-srv-new"]}
+  },
+  "PostureChecks": {
+    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "Policies": [
+    {
+      "ID": "pol-dest",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-version"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json
new file mode 100644
index 000000000..a661fa53e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Source-side NB-version posture check: peer-b (0.40.0) fails the 0.45.0 minimum, so peer-c must not see it and peer-b itself gets no policy connectivity.",
+  "peers": [
+    "peer-b",
+    "peer-c"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json
new file mode 100644
index 000000000..201be294f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "6",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json
new file mode 100644
index 000000000..009d00490
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json
@@ -0,0 +1,65 @@
+{
+  "Serial": "6",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdhdGVk"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdhdGVk"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json
new file mode 100644
index 000000000..4962e8b6a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json
@@ -0,0 +1,36 @@
+{
+  "Network": {"Serial": 6},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.40.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "PostureChecks": {
+    "chk-ver": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "PostureCheckXIDToPublicID": {"chk-ver": "chk-ver-pub"},
+  "Policies": [
+    {
+      "ID": "pol-gated",
+      "PublicID": "pol-gated-pub",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-ver"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Bidirectional": true,
+          "Ports": ["443"],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json
new file mode 100644
index 000000000..9f7f86860
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Geo location posture check in allow mode. An entry naming only a country matches the whole country, so peer-de passes; an entry naming a city must match that city exactly, so peer-us-ny passes while peer-us-bos does not. peer-fr matches nothing and fails. peer-nowhere has no location at all, which the check reports as an error, and an errored check denies — so it fails too.",
+  "peers": ["peer-srv", "peer-de", "peer-us-bos", "peer-nowhere"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json
new file mode 100644
index 000000000..9a07139fe
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-de.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-de.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json
new file mode 100644
index 000000000..aed97a69f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.5/10",
+    "sshConfig": {},
+    "fqdn": "peer-nowhere.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-nowhere.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.5"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json
new file mode 100644
index 000000000..9f3fcb1ba
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "9nwvdE0wik6Fcs8Tw6WBnmOqGZmzdiTR4VZAdRBOJF4=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-us-ny.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-de.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-de.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          },
+          {
+            "Name": "peer-us-ny.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json
new file mode 100644
index 000000000..624666c36
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-us-bos.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-us-bos.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json
new file mode 100644
index 000000000..b92840266
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json
@@ -0,0 +1,46 @@
+{
+  "Network": {"Serial": 31},
+  "Peers": {
+    "peer-de": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-us-ny": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "New York"}},
+    "peer-us-bos": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "Boston"}},
+    "peer-fr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}},
+    "peer-nowhere": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-de", "peer-us-ny", "peer-us-bos", "peer-fr", "peer-nowhere"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-geo": {
+      "Checks": {
+        "GeoLocationCheck": {
+          "Action": "allow",
+          "Locations": [
+            {"CountryCode": "DE"},
+            {"CountryCode": "US", "CityName": "New York"}
+          ]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-geo",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-geo"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json
new file mode 100644
index 000000000..f234b5280
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Geo location posture check in deny mode: matching the list rejects, not matching passes, so peer-ru is excluded and peer-de is admitted. peer-nowhere has no location and fails here as well — a missing location is an error and errors deny in both modes, so deny mode is not a way to admit peers whose location is unknown.",
+  "peers": ["peer-srv", "peer-ru", "peer-nowhere"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json
new file mode 100644
index 000000000..e73b77d9e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-nowhere.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-nowhere.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json
new file mode 100644
index 000000000..721267f37
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-ru.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-ru.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json
new file mode 100644
index 000000000..93883369e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json
@@ -0,0 +1,62 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-de.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-de.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWdlby1kZW55"
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWdlby1kZW55"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json
new file mode 100644
index 000000000..5edc50a38
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json
@@ -0,0 +1,40 @@
+{
+  "Network": {"Serial": 32},
+  "Peers": {
+    "peer-ru": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "RU", "CityName": "Moscow"}},
+    "peer-de": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-nowhere": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-ru", "peer-de", "peer-nowhere"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-geo-deny": {
+      "Checks": {
+        "GeoLocationCheck": {
+          "Action": "deny",
+          "Locations": [{"CountryCode": "RU"}]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-geo-deny",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-geo-deny"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json
new file mode 100644
index 000000000..c0eab5408
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "One posture check bundle holding two different checks. All checks in a bundle must pass, so only peer-both is admitted: peer-badgeo satisfies the version rule and peer-badversion satisfies the location rule, and each is still rejected on the other. This pins the AND semantics of a bundle rather than any-of.",
+  "peers": ["peer-srv", "peer-both", "peer-badgeo"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json
new file mode 100644
index 000000000..6a6272909
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "35",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-badgeo.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-badgeo.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json
new file mode 100644
index 000000000..7e18e7cb8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "35",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-both.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-both.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json
new file mode 100644
index 000000000..e91e8f777
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "35",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ilmSCJoVLTTY/Am7or8ES8R0hL/OdfE2FDK197pxc5o=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-both.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-both.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json
new file mode 100644
index 000000000..fbf7a5f1e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 35},
+  "Peers": {
+    "peer-both": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-badgeo": {"IP": "100.64.0.2", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}},
+    "peer-badversion": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.30.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-both", "peer-badgeo", "peer-badversion"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-combo": {
+      "Checks": {
+        "NBVersionCheck": {"MinVersion": "0.45.0"},
+        "GeoLocationCheck": {
+          "Action": "allow",
+          "Locations": [{"CountryCode": "DE"}]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-combo",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-combo"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json
new file mode 100644
index 000000000..d10bfc9ae
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Peer network range posture check in allow mode over 192.168.0.0/16. peer-office passes on its reported interface network, and peer-by-connip passes on the address it connected from, which the check folds in as a single-host prefix — so either source of address information can satisfy it. peer-remote is outside the range and peer-noaddr reports no address at all, which errors and therefore denies. Note this policy is tcp/22 without the peer's SSH flag, so no authorized users appear.",
+  "peers": ["peer-srv", "peer-office", "peer-by-connip", "peer-remote"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json
new file mode 100644
index 000000000..c9cb3ed0a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-by-connip.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-by-connip.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json
new file mode 100644
index 000000000..1b9834d12
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-office.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-office.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json
new file mode 100644
index 000000000..134a0aff2
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-remote.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-remote.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json
new file mode 100644
index 000000000..a8499e031
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "2Za6YHlJPJPv3hG/vDvdT0emXqIQADX9+wE8F1ZsgHU=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-by-connip.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "iVuRGJEIX4iqqF3zV01cxAEgyjXW3X4rrMoal6iA4fc=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-office.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-by-connip.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-office.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json
new file mode 100644
index 000000000..a258e855d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 33},
+  "Peers": {
+    "peer-office": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "192.168.1.10/24"}]}},
+    "peer-remote": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "10.0.0.5/8"}]}},
+    "peer-by-connip": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"ConnectionIP": "192.168.5.5"}},
+    "peer-noaddr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-office", "peer-remote", "peer-by-connip", "peer-noaddr"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-range": {
+      "Checks": {
+        "PeerNetworkRangeCheck": {
+          "Action": "allow",
+          "Ranges": ["192.168.0.0/16"]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-range",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-range"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["22"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json
new file mode 100644
index 000000000..b23a187d4
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "OS version posture check with per-OS minimums. peer-srv must see only the peers that satisfy their own platform's rule: the Linux peer on kernel 6.1 (the check compares the part before the first dash) and the macOS peer on 14.2. The old Linux and macOS peers fail. peer-win fails too even though its version looks modern, because the check defines no Windows minimum and a platform with no rule configured is treated as failing, not as unrestricted — a surprising rule worth freezing. Each rejected peer also loses its own view of peer-srv, since the policy is its only connectivity.",
+  "peers": ["peer-srv", "peer-lin-ok", "peer-lin-old", "peer-win"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json
new file mode 100644
index 000000000..f299b9fcf
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-ok.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json
new file mode 100644
index 000000000..c0a5412fa
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-old.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-old.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json
new file mode 100644
index 000000000..91883d20b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "XpdB4aptfFjgsQOHfEO65dNozY8R7EIw3/alAnXjl+k=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-lin-ok.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "gRO02HiaHUKq2xTbYJhPsmGj06bK0HGU2tgL0pKB2yQ=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-mac-ok.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-mac-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json
new file mode 100644
index 000000000..0b0cafb26
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.5/10",
+    "sshConfig": {},
+    "fqdn": "peer-win.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-win.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.5"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json
new file mode 100644
index 000000000..018c0cc21
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 30},
+  "Peers": {
+    "peer-lin-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}},
+    "peer-lin-old": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "5.4.0-generic"}},
+    "peer-mac-ok": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "14.2"}},
+    "peer-mac-old": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "12.0"}},
+    "peer-win": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0", "GoOS": "windows", "KernelVersion": "10.0.19045"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-lin-ok", "peer-lin-old", "peer-mac-ok", "peer-mac-old", "peer-win"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-os": {
+      "Checks": {
+        "OSVersionCheck": {
+          "Linux": {"MinKernelVersion": "6.0.0"},
+          "Darwin": {"MinVersion": "13.0"}
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-os",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-os"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json
new file mode 100644
index 000000000..b730389a6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Process posture check, which picks the path for the peer's own platform. peer-lin-running and peer-mac-running each have their platform's process running and pass. peer-lin-stopped reports the same file but not running, so it fails — presence of the binary is not enough. peer-bsd runs an unsupported operating system, which the check reports as an error, and errors deny.",
+  "peers": ["peer-srv", "peer-lin-running", "peer-lin-stopped", "peer-bsd"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json
new file mode 100644
index 000000000..f75459d39
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.4/10",
+    "sshConfig": {},
+    "fqdn": "peer-bsd.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-bsd.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.4"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json
new file mode 100644
index 000000000..ddfa39472
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json
@@ -0,0 +1,62 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-running.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-running.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json
new file mode 100644
index 000000000..828a933f7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-stopped.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-stopped.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json
new file mode 100644
index 000000000..6eb409623
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json
@@ -0,0 +1,89 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "d9LCTf7vwqctprOKyF95j17uPWpRjEeirHfB75RIGlk=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-lin-running.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "rgP4xt50GcHp7fFgBoSt8yz5bp5AnOAVHMmOd+rTbR4=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-mac-running.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-running.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-mac-running.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json
new file mode 100644
index 000000000..b112e2235
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json
@@ -0,0 +1,70 @@
+{
+  "Network": {"Serial": 34},
+  "Peers": {
+    "peer-lin-running": {
+      "IP": "100.64.0.1",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "linux",
+        "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}]
+      }
+    },
+    "peer-lin-stopped": {
+      "IP": "100.64.0.2",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "linux",
+        "Files": [{"Path": "/usr/bin/agent"}]
+      }
+    },
+    "peer-mac-running": {
+      "IP": "100.64.0.3",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "darwin",
+        "Files": [{"Path": "/Applications/Agent.app", "ProcessIsRunning": true}]
+      }
+    },
+    "peer-bsd": {
+      "IP": "100.64.0.4",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "freebsd",
+        "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}]
+      }
+    },
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-lin-running", "peer-lin-stopped", "peer-mac-running", "peer-bsd"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-proc": {
+      "Checks": {
+        "ProcessCheck": {
+          "Processes": [
+            {"LinuxPath": "/usr/bin/agent", "MacPath": "/Applications/Agent.app"}
+          ]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-proc",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-proc"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json
new file mode 100644
index 000000000..b30b06243
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Posture check on a policy granting access to a network resource. peer-ok must receive the route to the resource through peer-r, while peer-bad fails the version check and must receive no route at all. The router's route firewall rule must narrow its SourceRanges to peer-ok's address only — a peer rejected by posture must not be permitted through the routing peer either, which is the enforcement that actually matters.",
+  "peers": ["peer-ok", "peer-bad", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json
new file mode 100644
index 000000000..b1aa1a494
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "38",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-bad.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-bad.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json
new file mode 100644
index 000000000..6aa688eef
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json
@@ -0,0 +1,56 @@
+{
+  "Serial": "38",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-ok.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.80.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json
new file mode 100644
index 000000000..192304ca6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "38",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "XPXITqbev8jVtIkumbPt5vpohe2OHWvhNGO3z2mgcxM=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-ok.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.80.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.80.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLXJlcw==",
+      "RouteID": "res-db:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json
new file mode 100644
index 000000000..840c5a7f8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json
@@ -0,0 +1,48 @@
+{
+  "Network": {"Serial": 38},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}},
+    "peer-bad": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.30.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-ok", "peer-bad"]}
+  },
+  "PostureChecks": {
+    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "Policies": [
+    {
+      "ID": "pol-res",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-version"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-db": [{"ID": "pol-res"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "NetworkID": "net-1",
+      "Name": "db-subnet",
+      "Type": "subnet",
+      "Prefix": "10.80.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json
new file mode 100644
index 000000000..203b89533
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "The same peer under two policies carrying different posture checks. peer-x is on an old agent version but in an allowed country, so the version-gated policy rejects it while the location-gated one admits it: it must reach peer-srv-b on 8443 and not peer-srv-a at all. Failing one policy's check must not leak into another policy's decision. peer-srv-a correspondingly sees nobody, peer-srv-b sees peer-x.",
+  "peers": ["peer-x", "peer-srv-a", "peer-srv-b"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json
new file mode 100644
index 000000000..8008db39c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "36",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-srv-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json
new file mode 100644
index 000000000..c7bc45742
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "36",
+  "peerConfig": {
+    "address": "100.64.0.12/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv-b.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "N1oKwtIwXdTDF0HdKDss0gPhxkqz+/Z/91734QZVCng=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-x.netbird.test",
+      "agentVersion": "0.40.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-srv-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          },
+          {
+            "Name": "peer-x.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json
new file mode 100644
index 000000000..461454233
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "36",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-x.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "fT/Mb0QBqXGx2q06gXqizvlXp5uz+ErGCaKmCEXVbMk=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv-b.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-srv-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          },
+          {
+            "Name": "peer-x.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.12",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json
new file mode 100644
index 000000000..2e0512194
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json
@@ -0,0 +1,55 @@
+{
+  "Network": {"Serial": 36},
+  "Peers": {
+    "peer-x": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.40.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-srv-a": {"IP": "100.64.0.11", "Meta": {"WtVersion": "1.0.0"}},
+    "peer-srv-b": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-x"]},
+    "grp-srv-a": {"Peers": ["peer-srv-a"]},
+    "grp-srv-b": {"Peers": ["peer-srv-b"]}
+  },
+  "PostureChecks": {
+    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}},
+    "chk-geo": {
+      "Checks": {
+        "GeoLocationCheck": {"Action": "allow", "Locations": [{"CountryCode": "DE"}]}
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-strict",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-version"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv-a"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-lenient",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-geo"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv-b"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json
new file mode 100644
index 000000000..5f9e98ea7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "A reverse-proxy service targeting a domain network resource. The synthesised proxy-access ACL is a resource policy too: on the account path the resource-policy map was built after injection, so the routing peer must carry a route firewall rule sourced from the proxy peer for the resource's domain. The store reads the policies table and ResourcePolicies never holds it, so only the synthesis puts it there.",
+  "peers": [
+    "router-peer",
+    "proxy-peer"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json
new file mode 100644
index 000000000..a9e74061f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json
@@ -0,0 +1,60 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.99/10",
+    "sshConfig": {},
+    "fqdn": "proxy-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "router-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:router-peer",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json
new file mode 100644
index 000000000..d0bb48a3b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json
@@ -0,0 +1,80 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "router-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
+      "allowedIps": [
+        "100.64.0.99/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "proxy-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:router-peer",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "router-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.99/32"
+      ],
+      "destination": "192.0.2.0/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "range": {
+          "start": 443,
+          "end": 443
+        }
+      },
+      "isDynamic": true,
+      "domains": [
+        "app.internal"
+      ],
+      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt",
+      "RouteID": "res-domain:router-peer"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json
new file mode 100644
index 000000000..cbf729d9f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json
@@ -0,0 +1,44 @@
+{
+  "Network": {"Serial": 32},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "router-peer": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}},
+    "proxy-peer": {
+      "IP": "100.64.0.99",
+      "Meta": {"WtVersion": "0.60.0"},
+      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
+    }
+  },
+  "NetworkResources": [
+    {
+      "ID": "res-domain",
+      "NetworkID": "net-1",
+      "Name": "app-domain",
+      "Type": "domain",
+      "Domain": "app.internal",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "router-peer": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  },
+  "ProxyTargetedDomainResourceIDs": {"res-domain": {}},
+  "Services": [
+    {
+      "ID": "svc-1",
+      "Enabled": true,
+      "Mode": "http",
+      "ProxyCluster": "eu.proxy.netbird.io",
+      "Targets": [
+        {
+          "Enabled": true,
+          "Protocol": "https",
+          "TargetID": "res-domain",
+          "TargetType": "domain"
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json
new file mode 100644
index 000000000..d8450505f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "A reverse-proxy service targeting a peer. The proxy-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the cluster's embedded proxy peer reach the target on the target's port: proxy-peer gets an OUT rule to app-peer on TCP 8080 and app-peer the matching IN rule. Without the synthesis both maps are empty of each other.",
+  "peers": [
+    "proxy-peer",
+    "app-peer"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json
new file mode 100644
index 000000000..0e71a62ba
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "app-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
+      "allowedIps": [
+        "100.64.0.99/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "proxy-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "app-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          },
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.99",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 8080,
+          "end": 8080
+        }
+      },
+      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json
new file mode 100644
index 000000000..4c6053319
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json
@@ -0,0 +1,65 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.99/10",
+    "sshConfig": {},
+    "fqdn": "proxy-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "/wFxrqMtMwWNZak/f0UDddUkCZMTmxNuiuk4/RGGNcY=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "app-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "app-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          },
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 8080,
+          "end": 8080
+        }
+      },
+      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json
new file mode 100644
index 000000000..b4645fb90
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json
@@ -0,0 +1,29 @@
+{
+  "Network": {"Serial": 30},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "proxy-peer": {
+      "IP": "100.64.0.99",
+      "Meta": {"WtVersion": "0.60.0"},
+      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
+    },
+    "app-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Services": [
+    {
+      "ID": "svc-1",
+      "Enabled": true,
+      "Mode": "http",
+      "ProxyCluster": "eu.proxy.netbird.io",
+      "Targets": [
+        {
+          "Enabled": true,
+          "Port": 8080,
+          "Protocol": "http",
+          "TargetID": "app-peer",
+          "TargetType": "peer"
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json
new file mode 100644
index 000000000..8ef26f4cc
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "A private reverse-proxy service. The private-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the service's AccessGroups reach the cluster's embedded proxy peer on TCP 80 and 443: user-peer gets OUT rules on both ports and proxy-peer the matching IN rules. Without the synthesis both maps are empty of each other.",
+  "peers": [
+    "user-peer",
+    "proxy-peer"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json
new file mode 100644
index 000000000..7022f0c70
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json
@@ -0,0 +1,75 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.99/10",
+    "sshConfig": {},
+    "fqdn": "proxy-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "v19TN/CymWAs/WppcLjz3atM+t4ySNdImGtoPu4wnT8=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "user-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          },
+          {
+            "Name": "user-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 443,
+          "end": 443
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 80,
+          "end": 80
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json
new file mode 100644
index 000000000..7197abf22
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json
@@ -0,0 +1,77 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "user-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
+      "allowedIps": [
+        "100.64.0.99/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "proxy-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          },
+          {
+            "Name": "user-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.99",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 443,
+          "end": 443
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    },
+    {
+      "PeerIP": "100.64.0.99",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 80,
+          "end": 80
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json
new file mode 100644
index 000000000..066849bd4
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json
@@ -0,0 +1,26 @@
+{
+  "Network": {"Serial": 31},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "user-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}},
+    "other-peer": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
+    "proxy-peer": {
+      "IP": "100.64.0.99",
+      "Meta": {"WtVersion": "0.60.0"},
+      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
+    }
+  },
+  "Groups": {
+    "grp-admins": {"Peers": ["user-peer"]}
+  },
+  "Services": [
+    {
+      "ID": "svc-1",
+      "Enabled": true,
+      "Private": true,
+      "Mode": "http",
+      "ProxyCluster": "eu.proxy.netbird.io",
+      "AccessGroups": ["grp-admins", "grp-deleted"]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json
new file mode 100644
index 000000000..8d99a239c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Classic route with access control groups: instead of the wide-open default permit, peer-r's route firewall rule must be narrowed to the policy that targets the ACL group — protocol and port from that rule, SourceRanges limited to the two source peers. peer-a still receives the route itself.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json
new file mode 100644
index 000000000..c52aecce9
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json
@@ -0,0 +1,76 @@
+{
+  "Serial": "27",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-acl",
+      "Network": "10.70.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.9",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.9",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json
new file mode 100644
index 000000000..cc03ef08c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json
@@ -0,0 +1,119 @@
+{
+  "Serial": "27",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-acl",
+      "Network": "10.70.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    }
+  ],
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32",
+        "100.64.0.2/32"
+      ],
+      "destination": "10.70.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 3306
+      },
+      "PolicyID": "cG9sLWFjbA==",
+      "RouteID": "rt-acl"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json
new file mode 100644
index 000000000..d7bfd0d08
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json
@@ -0,0 +1,45 @@
+{
+  "Network": {"Serial": 27},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-acl": {"Peers": ["peer-r"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-acl",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["3306"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-acl"]
+        }
+      ]
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-acl",
+      "NetID": "db-net",
+      "Network": "10.70.0.0/24",
+      "NetworkType": 1,
+      "Peer": "peer-r",
+      "PeerID": "peer-r",
+      "Groups": ["grp-dev"],
+      "AccessControlGroups": ["grp-acl"],
+      "Metric": 9999,
+      "Masquerade": true,
+      "Enabled": true
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json
new file mode 100644
index 000000000..52d45346a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Classic route served by a peer group instead of one peer: each routing peer's copy takes the route id with its own peer id appended and drops the PeerGroups field, and the distribution group's peer-a must receive both copies as an HA pair. Each router receives only its own copy plus a default-permit route firewall rule, because the route carries no access control groups. A policy connecting the two groups is required — route distribution follows peers the target may already talk to.",
+  "peers": ["peer-a", "peer-r1"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json
new file mode 100644
index 000000000..fd9e32274
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json
@@ -0,0 +1,114 @@
+{
+  "Serial": "26",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "allowedIps": [
+        "100.64.0.11/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r1.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r2.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-ha:peer-r1",
+      "Network": "10.60.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-net"
+    },
+    {
+      "ID": "rt-ha:peer-r2",
+      "Network": "10.60.0.0/24",
+      "NetworkType": "1",
+      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r1.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          },
+          {
+            "Name": "peer-r2.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.11",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.11",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json
new file mode 100644
index 000000000..bd8bdecd0
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "26",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-r1.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-ha:peer-r1",
+      "Network": "10.60.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r1.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          },
+          {
+            "Name": "peer-r2.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "0.0.0.0/0"
+      ],
+      "destination": "10.60.0.0/24",
+      "protocol": "ALL",
+      "portInfo": {},
+      "RouteID": "rt-ha:peer-r1"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json
new file mode 100644
index 000000000..8b81a585f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 26},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-routers": {"Peers": ["peer-r1", "peer-r2"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-conn",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8080"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-routers"]
+        }
+      ]
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-ha",
+      "NetID": "ha-net",
+      "Network": "10.60.0.0/24",
+      "NetworkType": 1,
+      "PeerGroups": ["grp-routers"],
+      "Groups": ["grp-dev"],
+      "Metric": 9999,
+      "Masquerade": true,
+      "Enabled": true
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json
new file mode 100644
index 000000000..a94d3653f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Classic route distributed to grp-dev plus a network resource behind router peer-r with a resource policy; peer-a gets routes and route firewall rules, peer-r gets the routing-peer view.",
+  "peers": [
+    "peer-a",
+    "peer-r"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json
new file mode 100644
index 000000000..9426bd846
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json
@@ -0,0 +1,86 @@
+{
+  "Serial": "7",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.10.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db",
+      "keepRoute": true
+    },
+    {
+      "ID": "rt-1",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "office-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.9",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.9",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json
new file mode 100644
index 000000000..e5971796d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json
@@ -0,0 +1,138 @@
+{
+  "Serial": "7",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.10.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db",
+      "keepRoute": true
+    },
+    {
+      "ID": "rt-1",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "office-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "0.0.0.0/0"
+      ],
+      "destination": "10.20.0.0/24",
+      "protocol": "ALL",
+      "portInfo": {},
+      "RouteID": "rt-1"
+    },
+    {
+      "sourceRanges": [
+        "100.64.0.1/32",
+        "100.64.0.2/32"
+      ],
+      "destination": "10.10.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLWRi",
+      "RouteID": "res-db:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json
new file mode 100644
index 000000000..795047f50
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json
@@ -0,0 +1,97 @@
+{
+  "Network": {"Serial": 7},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-routers": {"Peers": ["peer-r"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-conn",
+      "PublicID": "pol-conn-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Bidirectional": true,
+          "Ports": ["8080"],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-routers"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-db",
+      "PublicID": "pol-db-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {
+    "res-db": [
+      {
+        "ID": "pol-db",
+        "PublicID": "pol-db-pub",
+        "Enabled": true,
+        "Rules": [
+          {
+            "Enabled": true,
+            "Action": "accept",
+            "Protocol": "tcp",
+            "Ports": ["5432"],
+            "Sources": ["grp-dev"],
+            "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+          }
+        ]
+      }
+    ]
+  },
+  "Routes": [
+    {
+      "ID": "rt-1",
+      "PublicID": "rt-1-pub",
+      "NetID": "office-net",
+      "Network": "10.20.0.0/24",
+      "NetworkType": 1,
+      "Peer": "peer-r",
+      "PeerID": "peer-r",
+      "Metric": 9999,
+      "Masquerade": true,
+      "Enabled": true,
+      "Groups": ["grp-dev"]
+    }
+  ],
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "PublicID": "res-db-pub",
+      "NetworkID": "net-1",
+      "Name": "db",
+      "Type": "subnet",
+      "Prefix": "10.10.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  },
+  "NetworkXIDToPublicID": {"net-1": "net-1-pub"}
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json
new file mode 100644
index 000000000..78ac576d9
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "netbird-ssh with AuthorizedGroups: grp-admins members may log in as root, grp-oncall (empty local-user list) as any machine user; peer-srv must receive both mappings in SshAuth, clients get plain TCP firewall rules. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: encodeAuthorizedGroups/encodeGroupIDToUserIDs translate group keys via components.Groups, which never holds user-only groups, so the wire loses every authorized user while PeerConfig still reports sshEnabled — the peer runs sshd and denies every login. Pre-existing on main since PR #6711, not a regression. Fix the encoder, do not weaken this expectation.",
+  "peers": ["peer-srv", "peer-a"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json
new file mode 100644
index 000000000..6560bc74d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json
@@ -0,0 +1,63 @@
+{
+  "Serial": "10",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json
new file mode 100644
index 000000000..0a66bcbf0
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json
@@ -0,0 +1,109 @@
+{
+  "Serial": "10",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "CF6q+CJTtcJE8MVIcpPyOw==",
+      "zSsmm7BAxWD/EuunyETFXA==",
+      "0M0MizUGgS6HAaJa0LjGKQ=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          2
+        ]
+      },
+      "root": {
+        "indexes": [
+          0,
+          1
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json
new file mode 100644
index 000000000..de0788ac9
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json
@@ -0,0 +1,37 @@
+{
+  "Network": {"Serial": 10},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "GroupIDToUserIDs": {
+    "grp-admins": ["user-x", "user-y"],
+    "grp-oncall": ["user-z"]
+  },
+  "Policies": [
+    {
+      "ID": "pol-ssh",
+      "PublicID": "pol-ssh-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "netbird-ssh",
+          "PortRanges": [{"Start": 22022, "End": 22022}],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"],
+          "AuthorizedGroups": {
+            "grp-admins": ["root"],
+            "grp-oncall": []
+          }
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json
new file mode 100644
index 000000000..e4799638e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "netbird-ssh with a single AuthorizedUser: peer-srv's SshAuth maps the wildcard machine user to exactly user-solo.",
+  "peers": ["peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json
new file mode 100644
index 000000000..dde0d4014
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json
@@ -0,0 +1,74 @@
+{
+  "Serial": "11",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaC11c2Vy"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "/6cwl49UgLozU42NCr0RUA=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          0
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json
new file mode 100644
index 000000000..1c92329a8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json
@@ -0,0 +1,29 @@
+{
+  "Network": {"Serial": 11},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-ssh-user",
+      "PublicID": "pol-ssh-user-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "netbird-ssh",
+          "PortRanges": [{"Start": 22022, "End": 22022}],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"],
+          "AuthorizedUser": "user-solo"
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json
new file mode 100644
index 000000000..8d4fb6c1a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "netbird-ssh with neither AuthorizedGroups nor AuthorizedUser falls back to the account AllowedUserIDs under the wildcard machine user — and works with the peer's own SSHEnabled left off, unlike legacy SSH.",
+  "peers": ["peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json
new file mode 100644
index 000000000..e63768a95
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json
@@ -0,0 +1,76 @@
+{
+  "Serial": "12",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaC1hbnk="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "O8fBfcakRSAM4gX+YBNe+w==",
+      "1vwcS03btOdBRX0dhz0NRg=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          0,
+          1
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json
new file mode 100644
index 000000000..97bfe3342
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json
@@ -0,0 +1,29 @@
+{
+  "Network": {"Serial": 12},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "AllowedUserIDs": {"user-1": {}, "user-2": {}},
+  "Policies": [
+    {
+      "ID": "pol-ssh-any",
+      "PublicID": "pol-ssh-any-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "netbird-ssh",
+          "PortRanges": [{"Start": 22022, "End": 22022}],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json
new file mode 100644
index 000000000..1427fbd19
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "A tcp/22 policy implies legacy SSH only when the destination peer has SSHEnabled; here it does not, so peer-srv gets the firewall rules but no authorized users.",
+  "peers": ["peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json
new file mode 100644
index 000000000..1ab6d4697
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "13",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXRjcDIy"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXRjcDIy"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json
new file mode 100644
index 000000000..f00d0f06e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json
@@ -0,0 +1,30 @@
+{
+  "Network": {"Serial": 13},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "AllowedUserIDs": {"user-1": {}},
+  "Policies": [
+    {
+      "ID": "pol-tcp22",
+      "PublicID": "pol-tcp22-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["22"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/network_map_db/factory/db_store.go b/management/internals/network_map_db/factory/db_store.go
new file mode 100644
index 000000000..3eea0ae69
--- /dev/null
+++ b/management/internals/network_map_db/factory/db_store.go
@@ -0,0 +1,76 @@
+package networkmapdbfactory
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
+	networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite"
+	"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
+	"github.com/netbirdio/netbird/management/server/settings"
+	"github.com/netbirdio/netbird/management/server/store"
+	"github.com/netbirdio/netbird/management/server/types"
+	log "github.com/sirupsen/logrus"
+)
+
+const storeSqliteFileName = "store.db"
+
+var ErrNotSupportedStoreEngine = errors.New("unsupported store engine")
+
+func NewNetworkMapDBStore(
+	ctx context.Context,
+	kind types.Engine,
+	dataDir string,
+	integratedPeerValidator integrated_validator.IntegratedValidator,
+	extraSettingsManager settings.Manager) (*networkmapdb.NetworkMapDBStoreImpl, error) {
+	switch kind {
+	case types.SqliteStoreEngine:
+		log.WithContext(ctx).Info("networkmap store is using SQLite")
+		storeFile := storeSqliteFileName
+		if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" {
+			storeFile = envFile
+		}
+		store, err := networkmap_sqlite.NewSqliteStore(storeFile, dataDir)
+		if err != nil {
+			return nil, err
+		}
+		return &networkmapdb.NetworkMapDBStoreImpl{
+			Store:                   store,
+			IntegratedPeerValidator: integratedPeerValidator,
+			ExtraSettingsManager:    extraSettingsManager,
+		}, nil
+	case types.PostgresStoreEngine:
+		log.WithContext(ctx).Info("using Postgres store engine")
+		dsn, err := mustLookupDsnEnv()
+		if err != nil {
+			return nil, err
+		}
+
+		store, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
+		if err != nil {
+			return nil, err
+		}
+
+		return &networkmapdb.NetworkMapDBStoreImpl{
+			Store:                   store,
+			IntegratedPeerValidator: integratedPeerValidator,
+			ExtraSettingsManager:    extraSettingsManager,
+		}, nil
+	}
+
+	return nil, fmt.Errorf("networkmap store doesn't support engine %s, %w", kind, ErrNotSupportedStoreEngine)
+}
+
+func mustLookupDsnEnv() (string, error) {
+	if v, ok := os.LookupEnv(store.PostgresDsnEnv); ok {
+		return v, nil
+	}
+	if v, ok := os.LookupEnv(store.PostgresDsnEnvLegacy); ok {
+		return v, nil
+	}
+
+	return "", fmt.Errorf("%s env var must be set when using postgres networkmap store", store.PostgresDsnEnv)
+}
diff --git a/management/internals/network_map_db/network_map_data.go b/management/internals/network_map_db/network_map_data.go
new file mode 100644
index 000000000..f18cc8650
--- /dev/null
+++ b/management/internals/network_map_db/network_map_data.go
@@ -0,0 +1,277 @@
+package networkmapdb
+
+import (
+	"context"
+	"fmt"
+	"net/netip"
+	"strings"
+
+	"github.com/miekg/dns"
+	log "github.com/sirupsen/logrus"
+	"golang.org/x/exp/maps"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) {
+	tx, err := s.Store.BeginTx(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	acctSettings, err := tx.GetAccountSettings(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get account settings: %w", err))
+	}
+	dnsZones, err := tx.GetAppliedZoneCandidates(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get applied zone candidates: %w", err))
+	}
+	groups, resourceToGroupIdx, err := tx.GetGroups(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get groups: %w", err))
+	}
+	nsGroups, err := tx.GetNameServerGroups(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get nameserver groups: %w", err))
+	}
+	networkResources, err := tx.GetNetworkResources(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network resources: %w", err))
+	}
+	routers, err := tx.GetNetworkRouters(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network routers: %w", err))
+	}
+	network, err := tx.GetNetwork(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network: %w", err))
+	}
+	peers, proxyPeers, err := tx.GetPeers(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get peers: %w", err))
+	}
+	policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := tx.GetPolicies(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get policies: %w", err))
+	}
+	postureChecks, postureCheckXIDToPublicID, err := tx.GetPostureChecks(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get posture checks: %w", err))
+	}
+	routes, err := tx.GetRoutes(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get routes: %w", err))
+	}
+	networkXIDToPublicID, err := tx.GetNetworkXIDToPublicIdMap(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network xid to public id map: %w", err))
+	}
+	allowedUserIds, groupsToUserIds, err := tx.GetAllowedUsers(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get allowed users: %w", err))
+	}
+	dnsSettings, err := tx.GetDnsSettings(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get dns settings: %w", err))
+	}
+	domains, err := tx.GetDomains(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, err)
+	}
+	services, err := tx.GetPrivateServices(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, err)
+	}
+	proxyTargetedDomainResourceIDs, err := tx.GetProxyTargetedDomainResourceIDs(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get proxy targeted domain resources: %w", err))
+	}
+
+	if err = tx.CommitTx(ctx); err != nil {
+		log.WithContext(ctx).Warnf("failed to commit network map read transaction: %v", err)
+	}
+
+	resourcePolicies := buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx)
+
+	toret := networkmap.NetworkMapData{
+		AccountSettings:                &acctSettings,
+		DNSSettings:                    &dnsSettings,
+		Network:                        &network,
+		Peers:                          toMap(peers, func(p nmdata.Peer) string { return p.ID }),
+		Groups:                         toMap(groups, func(g nmdata.Group) string { return g.ID }),
+		Policies:                       toSliceOfPtrs(policies),
+		ResourcePolicies:               resourcePolicies,
+		Routes:                         toSliceOfPtrs(routes),
+		Routers:                        routers,
+		NameServerGroups:               toSliceOfPtrs(nsGroups),
+		NetworkResources:               toSliceOfPtrs(networkResources),
+		PostureChecks:                  toMap(postureChecks, func(pc nmdata.PostureChecks) string { return pc.ID }),
+		AllowedUserIDs:                 allowedUserIds,
+		GroupIDToUserIDs:               groupsToUserIds,
+		NetworkXIDToPublicID:           networkXIDToPublicID, // TODO (dmitri) maybe we can switch to public ids everywhere?
+		AppliedZoneCandidates:          dnsZones,
+		PrivateServiceCandidates:       buildPrivateServiceCandidates(services, domains, proxyPeers),
+		PostureCheckXIDToPublicID:      postureCheckXIDToPublicID,
+		ProxyTargetedDomainResourceIDs: proxyTargetedDomainResourceIDs,
+	}
+
+	extraSettings, err := s.ExtraSettingsManager.GetExtraSettings(ctx, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	toret.ValidatedPeers, err = s.IntegratedPeerValidator.GetValidatedPeers(ctx, accountId, maps.Values(toret.Groups), maps.Values(toret.Peers), extraSettings)
+	if err != nil {
+		return nil, err
+	}
+
+	return &toret, nil
+}
+
+func rollbackAndReturnError(ctx context.Context, tx NetworkMapDBStoreConn, err error) (*networkmap.NetworkMapData, error) {
+	if errr := tx.RollbackTx(ctx); errr != nil {
+		log.WithContext(ctx).Warnf("failed to rollback network map read transaction: %v", errr)
+	}
+	return nil, err
+}
+
+func toMap[T any](all []T, id func(t T) string) map[string]*T {
+	toret := make(map[string]*T, len(all))
+	for _, t := range all {
+		toret[id(t)] = &t
+	}
+	return toret
+}
+
+func toSliceOfPtrs[T any](all []T) []*T {
+	toret := make([]*T, 0, len(all))
+	for _, t := range all {
+		toret = append(toret, &t)
+	}
+	return toret
+}
+
+func serviceDomainZone(svc Service, ds []Domain) string {
+	if domainFromSuffix(svc.Domain.String, svc.ProxyCluster.String) {
+		return svc.ProxyCluster.String
+	}
+
+	var zoneName string
+	for _, domain := range ds {
+		if domain.TargetCluster.String != svc.ProxyCluster.String {
+			continue
+		}
+		if domainFromSuffix(svc.Domain.String, domain.Domain.String) && len(domain.Domain.String) > len(zoneName) {
+			zoneName = domain.Domain.String
+		}
+	}
+
+	return zoneName
+}
+
+func domainFromSuffix(domain, suffix string) bool {
+	if suffix == "" {
+		return false
+	}
+	return domain == suffix || strings.HasSuffix(domain, "."+suffix)
+}
+
+func buildPrivateServiceCandidates(svcs []Service, domains []Domain, proxyPeersByCluster map[string][]*nmdata.Peer) []networkmap.PrivateServiceCandidate {
+	var out []networkmap.PrivateServiceCandidate
+
+	if len(proxyPeersByCluster) == 0 {
+		return out
+	}
+
+	for _, svc := range svcs {
+		if !svc.Enabled.Bool || !svc.Private.Bool {
+			continue
+		}
+		if len(svc.AccessGroups) == 0 {
+			continue
+		}
+
+		domainZone := serviceDomainZone(svc, domains)
+		if domainZone == "" {
+			continue
+		}
+
+		// this is implied when domainZone != "", but for maintainability's sake the check is explicit
+		// TODO (dmitri) make this an invariant
+		if svc.Domain.String == "" {
+			continue
+		}
+		var records []nmdata.SimpleRecord
+		for _, proxyPeer := range proxyPeersByCluster[svc.ProxyCluster.String] {
+			if record, ok := recordForProxyPeer(svc.Domain.String, proxyPeer.IP); ok {
+				records = append(records, record)
+			}
+		}
+		if len(records) == 0 {
+			continue
+		}
+
+		out = append(out, networkmap.PrivateServiceCandidate{
+			AccessGroups: svc.AccessGroups,
+			Zone: nmdata.CustomZone{
+				Domain:               dns.Fqdn(domainZone),
+				Records:              records,
+				NonAuthoritative:     true,
+				SearchDomainDisabled: true,
+			},
+		})
+	}
+
+	return out
+}
+
+func recordForProxyPeer(fqdn string, ip netip.Addr) (nmdata.SimpleRecord, bool) {
+	if !ip.IsValid() {
+		return nmdata.SimpleRecord{}, false
+	}
+
+	return nmdata.SimpleRecord{
+		Name:  dns.Fqdn(fqdn),
+		Type:  int(dns.TypeA),
+		Class: "IN",
+		TTL:   5,
+		RData: ip.String(),
+	}, true
+}
+
+func buildResourcePolicies(networkResources []nmdata.NetworkResource,
+	policies []nmdata.Policy,
+	resourceToGroupIdx map[string]map[string]any,
+	policyToDestinationResourceIdx map[string]map[string]any,
+	policyToDestinationGroupIdx map[string]map[string]any) map[string][]*nmdata.Policy {
+
+	resourcePolicies := make(map[string][]*nmdata.Policy)
+	for _, resource := range networkResources {
+		if !resource.Enabled {
+			continue
+		}
+		networkResourceGroups := resourceToGroupIdx[resource.ID]
+		for _, policy := range policies {
+			if !policy.Enabled {
+				continue
+			}
+			if _, ok := policyToDestinationResourceIdx[policy.ID][resource.ID]; ok {
+				resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy) // TODO (dmitri) maybe use public id?
+				continue
+			}
+			if groupIds, ok := policyToDestinationGroupIdx[policy.ID]; ok {
+				for networkResourceGroup := range networkResourceGroups {
+					if _, ok := groupIds[networkResourceGroup]; ok {
+						resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy)
+						break
+					}
+				}
+			}
+		}
+	}
+
+	return resourcePolicies
+}
diff --git a/management/internals/network_map_db/network_map_data_test.go b/management/internals/network_map_db/network_map_data_test.go
new file mode 100644
index 000000000..925a23d1c
--- /dev/null
+++ b/management/internals/network_map_db/network_map_data_test.go
@@ -0,0 +1,399 @@
+package networkmapdb
+
+import (
+	"database/sql"
+	"net/netip"
+	"testing"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestDomainFromSuffix(t *testing.T) {
+	assert.False(t, domainFromSuffix("test", ""))
+	assert.False(t, domainFromSuffix("test", "suffix"))               // domain != suffix
+	assert.True(t, domainFromSuffix("test", "test"))                  // domain == suffix
+	assert.False(t, domainFromSuffix("test.anothersuffix", "suffix")) // domain doesn't contain suffix
+	assert.True(t, domainFromSuffix("test.suffix", "suffix"))         // domain contains suffix
+}
+
+func TestServiceDomainZone(t *testing.T) {
+	// shortcut -- service's domain is a subomain of proxy cluster
+	assert.Equal(t, "cluster",
+		serviceDomainZone(
+			Service{
+				Domain:       sql.NullString{Valid: true, String: "test.cluster"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+			[]Domain{}))
+	assert.Equal(t, "a.b", serviceDomainZone(
+		Service{
+			Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+			ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		[]Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "a-cluster"}},
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "b"}},
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}}, // should return this domain, as it's the longest match
+			{TargetCluster: sql.NullString{Valid: true, String: "b-cluster"}},
+		}))
+	// service and domain clusters don't match
+	assert.Empty(t, serviceDomainZone(
+		Service{
+			Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+			ProxyCluster: sql.NullString{Valid: true, String: "c-cluster"}},
+		[]Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		}))
+	// service domain is empty
+	assert.Empty(t, serviceDomainZone(
+		Service{
+			Domain:       sql.NullString{Valid: false, String: ""},
+			ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		[]Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		}))
+}
+
+func TestRecordForProxyPeer(t *testing.T) {
+	record, ok := recordForProxyPeer("test.cluster", netip.MustParseAddr("127.0.0.1"))
+	assert.True(t, ok)
+	assert.Equal(t, nmdata.SimpleRecord{
+		Name:  "test.cluster.",
+		Type:  1,
+		Class: "IN",
+		TTL:   5,
+		RData: "127.0.0.1",
+	}, record)
+
+	// invalid address
+	var addr netip.Addr
+	_, ok = recordForProxyPeer("test.cluster", addr)
+	assert.False(t, ok)
+}
+
+var empty []networkmap.PrivateServiceCandidate
+
+// empty proxyPeersByCluster results in empty []PrivateServiceCandidates
+func TestBuildPrivateServiceCandidates_EmptyProxyPeers(t *testing.T) {
+	assert.Equal(t, empty, buildPrivateServiceCandidates([]Service{}, []Domain{}, nil))
+}
+
+// disabled service returns an empty result
+func TestBuildPrivateServiceCandidates_DisabledService(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: false},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+// non-private service results in empty []PrivateServiceCandidates
+func TestBuildPrivateServiceCandidates_PublicService(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: false},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+// empty AccessList results in empty []PrivateServiceCandidates
+func TestBuildPrivateServiceCandidates_EmptyAccessList(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+// empty TragetCluster results in empty []PrivateServiceCandidates
+func TestBuildPrivateServiceCandidates_EmptyTargetCluster(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: ""},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+func TestBuildPrivateServiceCandidates_EmptyServiceDomain(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				Domain:       sql.NullString{Valid: true, String: ""},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+func TestBuildPrivateServiceCandidates_HappyPath(t *testing.T) {
+	assert.Equal(t, []networkmap.PrivateServiceCandidate{
+		{
+			AccessGroups: []string{"group-1", "group-2"},
+			Zone: nmdata.CustomZone{
+				Domain:               "a.b.",
+				SearchDomainDisabled: true,
+				NonAuthoritative:     true,
+				Records: []nmdata.SimpleRecord{
+					{
+						Name:  "test.a.b.",
+						Type:  1,
+						Class: "IN",
+						TTL:   5,
+						RData: "127.0.0.1",
+					},
+					{
+						Name:  "test.a.b.",
+						Type:  1,
+						Class: "IN",
+						TTL:   5,
+						RData: "127.0.0.2",
+					},
+				},
+			},
+		},
+		{
+			AccessGroups: []string{"group-1", "group-2"},
+			Zone: nmdata.CustomZone{
+				Domain:               "c.d.",
+				SearchDomainDisabled: true,
+				NonAuthoritative:     true,
+				Records: []nmdata.SimpleRecord{
+					{
+						Name:  "test.c.d.",
+						Type:  1,
+						Class: "IN",
+						TTL:   5,
+						RData: "127.0.0.3",
+					},
+					{
+						Name:  "test.c.d.",
+						Type:  1,
+						Class: "IN",
+						TTL:   5,
+						RData: "127.0.0.4",
+					},
+				},
+			},
+		},
+	},
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.c.d"},
+				ProxyCluster: sql.NullString{Valid: true, String: "a-cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+			{TargetCluster: sql.NullString{Valid: true, String: "a-cluster"},
+				Domain: sql.NullString{Valid: true, String: "c.d"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+// disabled network resource shouldn't be in the resulting map
+func TestBuildResourcePolicies_DisabledNetworkResource(t *testing.T) {
+	networkResources := []nmdata.NetworkResource{
+		{ID: "net-res-1", Enabled: false},
+	}
+	policies := []nmdata.Policy{
+		{ID: "policy-1", Enabled: true},
+	}
+	resourceToGroupIdx := map[string]map[string]any{}
+	policyToDestinationResourceIdx := map[string]map[string]any{
+		"policy-1": {
+			"net-res-1": struct{}{},
+			"net-res-3": struct{}{},
+		},
+	}
+	policyToDestinationGroupIdx := map[string]map[string]any{}
+
+	assert.Empty(t, buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx))
+}
+
+// disabled policy shouldn't be in the resulting map
+func TestBuildResourcePolicies_DisabledPolicy(t *testing.T) {
+	networkResources := []nmdata.NetworkResource{
+		{ID: "net-res-1", Enabled: true},
+	}
+	policies := []nmdata.Policy{
+		{ID: "policy-1", Enabled: false},
+	}
+	resourceToGroupIdx := map[string]map[string]any{}
+	policyToDestinationResourceIdx := map[string]map[string]any{
+		"policy-1": {
+			"net-res-1": struct{}{},
+			"net-res-3": struct{}{},
+		},
+	}
+	policyToDestinationGroupIdx := map[string]map[string]any{}
+
+	assert.Empty(t, buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx))
+}
+
+// build ResourcePolicies via PolicyToDestinationResourceIdx only
+func TestBuildResourcePolicies_ViaPolicyToDestinationResourceIdx(t *testing.T) {
+	networkResources := []nmdata.NetworkResource{
+		{ID: "net-res-1", Enabled: true},
+		{ID: "net-res-2", Enabled: true},
+		{ID: "net-res-3", Enabled: true},
+	}
+	policies := []nmdata.Policy{
+		{ID: "policy-1", Enabled: true},
+		{ID: "policy-2", Enabled: true},
+		{ID: "policy-3", Enabled: true},
+	}
+	resourceToGroupIdx := map[string]map[string]any{}
+	policyToDestinationResourceIdx := map[string]map[string]any{
+		"policy-1": {
+			"net-res-1": struct{}{},
+			"net-res-3": struct{}{},
+		},
+		"policy-2": {
+			"net-res-2": struct{}{},
+		},
+		"policy-3": {
+			"net-res-1": struct{}{},
+			"net-res-2": struct{}{},
+		},
+	}
+	policyToDestinationGroupIdx := map[string]map[string]any{}
+
+	resourceToPolicies := buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx)
+
+	assert.Equal(t, map[string][]*nmdata.Policy{
+		"net-res-1": {
+			{ID: "policy-1", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+		"net-res-2": {
+			{ID: "policy-2", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+		"net-res-3": {
+			{ID: "policy-1", Enabled: true},
+		},
+	}, resourceToPolicies)
+}
+
+// build ResourcePolicies via PolicyToDestinationGroupIdx only
+func TestBuildResourcePolicies_ViaPolicyToDestinationGroupIdx(t *testing.T) {
+	networkResources := []nmdata.NetworkResource{
+		{ID: "net-res-1", Enabled: true},
+		{ID: "net-res-2", Enabled: true},
+		{ID: "net-res-3", Enabled: true},
+	}
+	policies := []nmdata.Policy{
+		{ID: "policy-1", Enabled: true},
+		{ID: "policy-2", Enabled: true},
+		{ID: "policy-3", Enabled: true},
+	}
+	resourceToGroupIdx := map[string]map[string]any{
+		"net-res-1": {
+			"group-1": struct{}{},
+			"group-2": struct{}{},
+		},
+		"net-res-2": {
+			"group-2": struct{}{},
+			"group-3": struct{}{},
+		},
+		"net-res-3": {
+			"group-3": struct{}{},
+			"group-4": struct{}{},
+		},
+	}
+	policyToDestinationResourceIdx := map[string]map[string]any{}
+	policyToDestinationGroupIdx := map[string]map[string]any{
+		"policy-1": {
+			"group-1": struct{}{},
+			"group-2": struct{}{},
+		},
+		"policy-2": {
+			"group-1": struct{}{},
+			"group-4": struct{}{},
+		},
+		"policy-3": {
+			"group-1": struct{}{},
+			"group-3": struct{}{},
+		},
+	}
+
+	resourceToPolicies := buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx)
+
+	assert.Equal(t, map[string][]*nmdata.Policy{
+		"net-res-1": {
+			{ID: "policy-1", Enabled: true},
+			{ID: "policy-2", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+		"net-res-2": {
+			{ID: "policy-1", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+		"net-res-3": {
+			{ID: "policy-2", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+	}, resourceToPolicies)
+}
diff --git a/management/internals/network_map_db/pgsql/account_settings.go b/management/internals/network_map_db/pgsql/account_settings.go
new file mode 100644
index 000000000..cd5a36e35
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/account_settings.go
@@ -0,0 +1,61 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"encoding/json"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetAccountSettingsQuery = `
+	select settings_peer_login_expiration_enabled as peer_login_expiration_enabled,
+	settings_peer_login_expiration as peer_login_expiration,
+	settings_peer_inactivity_expiration_enabled as peer_inactivity_expiration_enabled,
+	settings_peer_inactivity_expiration as peer_inactivity_expiration,
+	settings_dns_domain as dns_domain,
+	settings_ipv6_enabled_groups as ipv6_enabled_groups,
+	settings_routing_peer_dns_resolution_enabled as routing_peer_dns_resolution_enabled,
+	settings_lazy_connection_enabled as lazy_connection_enabled,
+	settings_auto_update_version as auto_update_version,
+	settings_auto_update_always as auto_update_always,
+	settings_metrics_push_enabled as metrics_push_enabled
+	from accounts
+	where id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) {
+	rows, err := pgc.Conn.Query(ctx, GetAccountSettingsQuery, accountId)
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	settings, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[networkmapdb.Account])
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	settingsInfo := nmdata.AccountSettingsInfo{
+		PeerLoginExpirationEnabled:      settings.PeerLoginExpirationEnabled.Bool,
+		PeerLoginExpiration:             time.Duration(settings.PeerLoginExpiration.Int64),
+		PeerInactivityExpirationEnabled: settings.PeerInactivityExpirationEnabled.Bool,
+		PeerInactivityExpiration:        time.Duration(settings.PeerInactivityExpiration.Int64),
+		DNSDomain:                       settings.DNSDomain.String,
+		RoutingPeerDNSResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled.Bool,
+		LazyConnectionEnabled:           settings.LazyConnectionEnabled.Bool,
+		AutoUpdateVersion:               settings.AutoUpdateVersion.String,
+		AutoUpdateAlways:                settings.AutoUpdateAlways.Bool,
+		MetricsPushEnabled:              settings.MetricsPushEnabled.Bool,
+	}
+	if settings.IPv6EnabledGroups != nil {
+		if err := json.Unmarshal(settings.IPv6EnabledGroups, &settingsInfo.IPv6EnabledGroups); err != nil {
+			return nmdata.AccountSettingsInfo{}, err
+		}
+	}
+
+	return settingsInfo, nil
+}
diff --git a/management/internals/network_map_db/pgsql/dns.go b/management/internals/network_map_db/pgsql/dns.go
new file mode 100644
index 000000000..b22b43903
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/dns.go
@@ -0,0 +1,33 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+)
+
+const (
+	GetAccountZonesQuery = `
+	select zones.id as id, domain, not enable_search_domain as search_domain_disabled, distribution_groups,
+	r.name as record_name, r.type as record_type, 'IN' record_class, r.ttl as record_ttl, r.content as record_rdata
+	from zones
+	left join records as r on r.zone_id = zones.id
+	where zones.account_id=$1 and zones.enabled
+	`
+)
+
+func (pgc *PgStoreConn) GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) {
+	rows, err := pgc.Conn.Query(ctx, GetAccountZonesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	zones, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Zone])
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ZonesToAppliedZoneCandidates(zones)
+}
diff --git a/management/internals/network_map_db/pgsql/dns_settings.go b/management/internals/network_map_db/pgsql/dns_settings.go
new file mode 100644
index 000000000..aec44e0f2
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/dns_settings.go
@@ -0,0 +1,45 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"encoding/json"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetDnsSettingsQuery = `
+	select dns_settings_disabled_management_groups
+	from accounts
+	where id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) {
+	rows, err := pgc.Conn.Query(ctx, GetDnsSettingsQuery, accountId)
+	if err != nil {
+		return nmdata.DNSSettings{}, err
+	}
+
+	return pgx.CollectOneRow(rows, rowToDnsSettings)
+}
+
+func rowToDnsSettings(row pgx.CollectableRow) (nmdata.DNSSettings, error) {
+	var value nmdata.DNSSettings
+	var settings json.RawMessage
+
+	if err := row.Scan(&settings); err != nil {
+		return value, err
+	}
+
+	if settings == nil {
+		return nmdata.DNSSettings{}, nil
+	}
+
+	if err := json.Unmarshal(settings, &value.DisabledManagementGroups); err != nil {
+		return value, err
+	}
+
+	return value, nil
+}
diff --git a/management/internals/network_map_db/pgsql/domain.go b/management/internals/network_map_db/pgsql/domain.go
new file mode 100644
index 000000000..8730007c5
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/domain.go
@@ -0,0 +1,25 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetDomainsQuery = `
+	select domain, target_cluster
+	from domains
+	where account_id=$1 and domain<>'' and target_cluster<>''
+	`
+)
+
+func (pgc *PgStoreConn) GetDomains(ctx context.Context, accountId string) ([]networkmapdb.Domain, error) {
+	rows, err := pgc.Conn.Query(ctx, GetDomainsQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	return pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Domain])
+}
diff --git a/management/internals/network_map_db/pgsql/group.go b/management/internals/network_map_db/pgsql/group.go
new file mode 100644
index 000000000..874e743a5
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/group.go
@@ -0,0 +1,64 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"reflect"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetGroupsQuery = `
+	select id, name, public_id, resources,
+	(
+	  select array_agg(group_peers.peer_id)
+      from group_peers
+	  where group_peers.group_id = groups.id and group_peers.account_id=$1
+	) as peers
+	from groups where account_id=$1
+	`
+)
+
+// we also return a resource-to-group index.
+// an alternative is to add json indexes, query this directly. Not sure how expensive
+// json indexes are. TODO (dmitri) verify and maybe change the implementation here.
+func (pgc *PgStoreConn) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) {
+	rows, err := pgc.Conn.Query(ctx, GetGroupsQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	groups, err := pgx.CollectRows(rows, pgx.RowToStructByName[group])
+	toret := make([]nmdata.Group, 0, len(groups))
+	resourceToGroupIdx := make(map[string]map[string]any)
+
+	for _, g := range groups {
+		dg := nmdata.Group{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&g), reflect.ValueOf(&dg))
+		if err != nil {
+			return nil, nil, err
+		}
+		toret = append(toret, dg)
+		for _, resource := range dg.Resources {
+			if _, ok := resourceToGroupIdx[resource.ID]; !ok {
+				resourceToGroupIdx[resource.ID] = make(map[string]any)
+			}
+			resourceToGroupIdx[resource.ID][g.ID] = struct{}{}
+		}
+	}
+
+	return toret, resourceToGroupIdx, err
+}
+
+type group struct {
+	ID        string
+	Name      sql.NullString
+	PublicID  sql.NullString
+	Resources json.RawMessage
+	Peers     []string
+}
diff --git a/management/internals/network_map_db/pgsql/nameserver.go b/management/internals/network_map_db/pgsql/nameserver.go
new file mode 100644
index 000000000..12f215edb
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/nameserver.go
@@ -0,0 +1,31 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNameserversQuery = `
+	select id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled
+	from name_server_groups
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNameserversQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	nsgroups, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.NameserverGroup])
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.NameserverGroup, nmdata.NameServerGroup](nsgroups)
+}
diff --git a/management/internals/network_map_db/pgsql/network.go b/management/internals/network_map_db/pgsql/network.go
new file mode 100644
index 000000000..5d7d33bcc
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/network.go
@@ -0,0 +1,39 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"reflect"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkQuery = `
+	select network_identifier as identifier, network_net as net, network_net_v6 as net_v6, network_dns as dns, network_serial as serial
+	from accounts
+	where id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNetworkQuery, accountId)
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	n, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[networkmapdb.AccountNetwork])
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	toret := nmdata.Network{}
+	err = networkmapdb.FromSqlTypesToSharedTypes(
+		reflect.ValueOf(&n), reflect.ValueOf(&toret))
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/pgsql/network_resource.go b/management/internals/network_map_db/pgsql/network_resource.go
new file mode 100644
index 000000000..48c9b0611
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/network_resource.go
@@ -0,0 +1,31 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkResourcesQuery = `
+	select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled
+	from network_resources
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNetworkResourcesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	netresorces, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Networkresource])
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Networkresource, nmdata.NetworkResource](netresorces)
+}
diff --git a/management/internals/network_map_db/pgsql/network_router.go b/management/internals/network_map_db/pgsql/network_router.go
new file mode 100644
index 000000000..42d5e3b28
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/network_router.go
@@ -0,0 +1,80 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"reflect"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkRouterQuery = `
+	select public_id, peer, network_id, masquerade, metric, enabled, peer_groups,
+	(
+	  select array_agg(group_peers.peer_id)
+	  from group_peers
+	  where group_peers.account_id=$1 and group_peers.group_id in (select json_array_elements_text(peer_groups::json))
+	) as peers_via_groups
+	from network_routers
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNetworkRouterQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	routers, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkrouter])
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]map[string]*nmdata.NetworkRouter)
+	for _, router := range routers {
+		if !router.Enabled.Bool {
+			continue
+		}
+
+		networkId := router.NetworkID.String
+		if networkId == "" {
+			return nil, fmt.Errorf("router with public_id %s doesn't have network_id set", router.PublicID.String)
+		}
+
+		nmdatarouter := nmdata.NetworkRouter{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&router), reflect.ValueOf(&nmdatarouter))
+		if err != nil {
+			return nil, err
+		}
+
+		if toret[networkId] == nil {
+			toret[networkId] = make(map[string]*nmdata.NetworkRouter)
+		}
+		if router.Peer.String != "" {
+			toret[networkId][router.Peer.String] = &nmdatarouter
+			continue
+		}
+		for _, peerId := range router.PeersViaGroups {
+			toret[networkId][peerId] = &nmdatarouter
+		}
+	}
+
+	return toret, nil
+}
+
+type networkrouter struct {
+	PublicID       sql.NullString
+	NetworkID      sql.NullString `nmap:"skip"`
+	Peer           sql.NullString `nmap:"skip"`
+	PeerGroups     json.RawMessage
+	PeersViaGroups []string `nmap:"skip"`
+	Masquerade     sql.NullBool
+	Metric         sql.NullInt64
+	Enabled        sql.NullBool
+}
diff --git a/management/internals/network_map_db/pgsql/networks.go b/management/internals/network_map_db/pgsql/networks.go
new file mode 100644
index 000000000..306356972
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/networks.go
@@ -0,0 +1,36 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetNetworksQuery = `
+	select id, public_id
+	from networks where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNetworksQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	networks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Network])
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]string)
+	for _, n := range networks {
+		if n.PublicID.Valid {
+			toret[n.ID] = n.PublicID.String
+		}
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/pgsql/peer.go b/management/internals/network_map_db/pgsql/peer.go
new file mode 100644
index 000000000..962669f7a
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/peer.go
@@ -0,0 +1,34 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPeersQuery = `
+	select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
+	peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
+	meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
+	location_country_code, location_city_name, location_connection_ip
+	from peers
+	where account_id = $1
+	`
+)
+
+func (pgc *PgStoreConn) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
+	rows, err := pgc.Conn.Query(ctx, GetPeersQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	peers, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Peer])
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return networkmapdb.ConvertToNmdataPeers(peers)
+}
diff --git a/management/internals/network_map_db/pgsql/pg_store.go b/management/internals/network_map_db/pgsql/pg_store.go
new file mode 100644
index 000000000..0cae610f8
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/pg_store.go
@@ -0,0 +1,128 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgconn"
+	"github.com/jackc/pgx/v5/pgtype"
+	"github.com/jackc/pgx/v5/pgxpool"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	pgMaxConnections    = 30
+	pgMinConnections    = 1
+	pgMaxConnLifetime   = 60 * time.Minute
+	pgHealthCheckPeriod = 1 * time.Minute
+)
+
+var _ networkmapdb.NetworkMapDBStore = &PgStore{}
+
+type PgStore struct {
+	Pool     *pgxpool.Pool
+	Location *time.Location
+}
+
+type PgStoreConn struct {
+	Conn pgInterface
+}
+
+type pgInterface interface {
+	Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
+	Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
+}
+
+var _ networkmapdb.NetworkMapDBStoreConn = &PgStoreConn{}
+
+func NewPostgresqlStore(ctx context.Context, dsn string) (*PgStore, error) {
+	pool, err := connectToPgDb(ctx, dsn)
+	if err != nil {
+		return nil, err
+	}
+
+	return &PgStore{Pool: pool}, nil
+}
+
+// This is used to control the timezone timestamps returned in.
+// By default pgx returns timestamps in the local timezone,
+// which may not be desirable.
+// use .UsingTimeZone(time.UTC) to return timestamps in UTC TZ
+func (p *PgStore) UsingTimeZone(location *time.Location) {
+	p.Location = location
+}
+
+func (p *PgStore) UsingConnection(c *pgx.Conn) networkmapdb.NetworkMapDBStoreConn {
+	if p.Location != nil {
+		c.TypeMap().RegisterType(&pgtype.Type{
+			Name:  "timestamptz",
+			OID:   pgtype.TimestamptzOID,
+			Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
+		})
+	}
+
+	return &PgStoreConn{Conn: c}
+}
+
+func (p *PgStore) Exec(ctx context.Context, query string, args ...any) error {
+	_, err := p.Pool.Exec(ctx, query, args...)
+	return err
+}
+
+func (p *PgStore) BeginTx(ctx context.Context) (networkmapdb.NetworkMapDBStoreConn, error) {
+	tx, err := p.Pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
+	if err != nil {
+		return nil, err
+	}
+	if p.Location != nil {
+		tx.Conn().TypeMap().RegisterType(&pgtype.Type{
+			Name:  "timestamptz",
+			OID:   pgtype.TimestamptzOID,
+			Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
+		})
+	}
+	return &PgStoreConn{Conn: tx}, nil
+}
+
+func (c *PgStoreConn) RollbackTx(ctx context.Context) error {
+	tx, ok := c.Conn.(pgx.Tx)
+	if !ok {
+		return fmt.Errorf("expected an pgx.Tx got %s", reflect.TypeOf(c.Conn).Kind())
+	}
+	return tx.Rollback(ctx)
+}
+
+func (c *PgStoreConn) CommitTx(ctx context.Context) error {
+	tx, ok := c.Conn.(pgx.Tx)
+	if !ok {
+		return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(c.Conn).Kind())
+	}
+	return tx.Commit(ctx)
+}
+
+func connectToPgDb(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
+	config, err := pgxpool.ParseConfig(dsn)
+	if err != nil {
+		return nil, fmt.Errorf("unable to parse database config: %w", err)
+	}
+
+	config.MaxConns = pgMaxConnections
+	config.MinConns = pgMinConnections
+	config.MaxConnLifetime = pgMaxConnLifetime
+	config.HealthCheckPeriod = pgHealthCheckPeriod
+
+	pool, err := pgxpool.NewWithConfig(ctx, config)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create connection pool: %w", err)
+	}
+
+	if err := pool.Ping(ctx); err != nil {
+		pool.Close()
+		return nil, fmt.Errorf("unable to ping database: %w", err)
+	}
+
+	return pool, nil
+}
diff --git a/management/internals/network_map_db/pgsql/policy.go b/management/internals/network_map_db/pgsql/policy.go
new file mode 100644
index 000000000..45927d7a2
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/policy.go
@@ -0,0 +1,34 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPoliciesQuery = `
+	select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled as rule_enabled, pr.action, pr.protocol, pr.bidirectional, 
+	pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges,
+	pr.authorized_groups, pr.authorized_user
+	from policies as p
+	left join policy_rules as pr on p.id = pr.policy_id 
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
+	rows, err := pgc.Conn.Query(ctx, GetPoliciesQuery, accountId)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	policies, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Policy])
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	return networkmapdb.ConvertToNmdataPolicy(policies)
+}
diff --git a/management/internals/network_map_db/pgsql/posture.go b/management/internals/network_map_db/pgsql/posture.go
new file mode 100644
index 000000000..aedfec2a5
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/posture.go
@@ -0,0 +1,44 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"reflect"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPostureChecksQuery = `
+	select id, public_id, checks
+	from posture_checks
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) {
+	rows, err := pgc.Conn.Query(ctx, GetPostureChecksQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	checks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.PostureChecks])
+	if err != nil {
+		return nil, nil, err
+	}
+
+	toret := make([]nmdata.PostureChecks, 0, len(checks))
+	idToPublicIDIdx := make(map[string]string)
+	for _, c := range checks {
+		checks := nmdata.PostureChecks{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&c), reflect.ValueOf(&checks))
+		if err != nil {
+			return nil, nil, err
+		}
+		toret = append(toret, checks)
+		idToPublicIDIdx[checks.ID] = c.PublicID.String
+	}
+
+	return toret, idToPublicIDIdx, nil
+}
diff --git a/management/internals/network_map_db/pgsql/route.go b/management/internals/network_map_db/pgsql/route.go
new file mode 100644
index 000000000..4f9a16c0e
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/route.go
@@ -0,0 +1,33 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetRoutesQuery = `
+	select id, account_id, public_id, network, domains, keep_route, net_id, description,
+	peer, peer as peer_id, peer_groups, network_type, masquerade, metric, enabled, 
+	groups, access_control_groups, skip_auto_apply
+	from routes
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) {
+	rows, err := pgc.Conn.Query(ctx, GetRoutesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	routes, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Route])
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Route, nmdata.Route](routes)
+}
diff --git a/management/internals/network_map_db/pgsql/service.go b/management/internals/network_map_db/pgsql/service.go
new file mode 100644
index 000000000..5d82046be
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/service.go
@@ -0,0 +1,51 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetServicesQuery = `
+	select enabled, private, array (select json_array_elements_text(access_groups::json)) as access_groups, proxy_cluster, domain
+	from services
+	where account_id=$1
+	`
+
+	GetProxyTargetedDomainResourcesQuery = `
+	select t.target_id
+	from targets as t
+	join services as s on s.id = t.service_id
+	where s.account_id=$1 and s.enabled and not coalesce(s.terminated, false)
+	and t.enabled and t.target_type='domain' and t.target_id is not null
+	`
+)
+
+func (pgc *PgStoreConn) GetPrivateServices(ctx context.Context, accountId string) ([]networkmapdb.Service, error) {
+	rows, err := pgc.Conn.Query(ctx, GetServicesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	return pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Service])
+}
+
+func (pgc *PgStoreConn) GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error) {
+	rows, err := pgc.Conn.Query(ctx, GetProxyTargetedDomainResourcesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	ids, err := pgx.CollectRows(rows, pgx.RowTo[string])
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]struct{}, len(ids))
+	for _, id := range ids {
+		toret[id] = struct{}{}
+	}
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/pgsql/user.go b/management/internals/network_map_db/pgsql/user.go
new file mode 100644
index 000000000..9e22c3575
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/user.go
@@ -0,0 +1,60 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+)
+
+const (
+	GetAllowedUserIdsQuery = `
+	select id, array (select json_array_elements_text(auto_groups::json)) as auto_groups
+	from users
+	where account_id=$1 and not blocked and not is_service_user
+	`
+
+	GetAllGroupIdQuery = `
+	select array_agg(id) from groups
+	where account_id=$1 and name='All'
+	`
+)
+
+func (pgc *PgStoreConn) GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) {
+	rows, err := pgc.Conn.Query(ctx, GetAllowedUserIdsQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	users, err := pgx.CollectRows(rows, pgx.RowToStructByName[user])
+	if err != nil {
+		return nil, nil, err
+	}
+
+	rows, err = pgc.Conn.Query(ctx, GetAllGroupIdQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+	allGroupIds, err := pgx.CollectOneRow(rows, pgx.RowTo[[]string])
+	if err != nil {
+		return nil, nil, err
+	}
+
+	userIdIdx := make(map[string]struct{})
+	groupIdToUserIds := make(map[string][]string)
+	for _, user := range users {
+		userIdIdx[user.ID] = struct{}{}
+		for _, groupId := range user.AutoGroups {
+			groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID)
+		}
+		for _, allgid := range allGroupIds {
+			groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID)
+		}
+	}
+
+	return userIdIdx, groupIdToUserIds, nil
+}
+
+type user struct {
+	ID         string
+	AutoGroups []string
+}
diff --git a/management/internals/network_map_db/shared_types.go b/management/internals/network_map_db/shared_types.go
new file mode 100644
index 000000000..bdd387877
--- /dev/null
+++ b/management/internals/network_map_db/shared_types.go
@@ -0,0 +1,472 @@
+package networkmapdb
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"reflect"
+
+	"github.com/miekg/dns"
+	"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
+	"github.com/netbirdio/netbird/management/server/settings"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+var ErrDnsUnsupportedRecordType = errors.New("unsupported record type")
+
+type NetworkMapDBStore interface { //nolint:revive // established name across the codebase
+	BeginTx(ctx context.Context) (NetworkMapDBStoreConn, error)
+	Exec(ctx context.Context, query string, args ...any) error
+}
+
+type NetworkMapDBStoreConn interface { //nolint:revive // established name across the codebase
+	GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error)
+	GetDomains(ctx context.Context, accountId string) ([]Domain, error)
+	GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error)
+	GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error)
+	GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error)
+	GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error)
+	GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error)
+	GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error)
+	GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error)
+	GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error)
+	GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error)
+	GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error)
+	GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error)
+	GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error)
+	GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error)
+	GetPrivateServices(ctx context.Context, accountId string) ([]Service, error)
+	GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error)
+
+	CommitTx(ctx context.Context) error
+	RollbackTx(ctx context.Context) error
+}
+
+type NetworkMapDBStoreImpl struct { //nolint:revive // established name across the codebase
+	Store                   NetworkMapDBStore
+	IntegratedPeerValidator integrated_validator.IntegratedValidator
+	ExtraSettingsManager    settings.Manager
+}
+
+// The order of fields in these structs is important.
+// Mapping of results of sqlite queries relies on the order
+// of the fields in these structs, when a query or a struct changes,
+// corresponding changes must be made to its counterpart.
+
+type Account struct {
+	PeerLoginExpirationEnabled      sql.NullBool
+	PeerLoginExpiration             sql.NullInt64
+	PeerInactivityExpirationEnabled sql.NullBool
+	PeerInactivityExpiration        sql.NullInt64
+	DNSDomain                       sql.NullString
+	IPv6EnabledGroups               []byte `nmap:"json"`
+	RoutingPeerDNSResolutionEnabled sql.NullBool
+	LazyConnectionEnabled           sql.NullBool
+	AutoUpdateVersion               sql.NullString
+	AutoUpdateAlways                sql.NullBool
+	MetricsPushEnabled              sql.NullBool
+}
+
+type Domain struct {
+	Domain        sql.NullString
+	TargetCluster sql.NullString
+}
+
+type Service struct {
+	Enabled      sql.NullBool
+	Private      sql.NullBool
+	AccessGroups []string
+	ProxyCluster sql.NullString
+	Domain       sql.NullString
+}
+
+type Zone struct {
+	Id                   string `nmap:"skip"`
+	Domain               sql.NullString
+	SearchDomainDisabled sql.NullBool
+	DistributionGroups   []byte         `nmap:"skip,json"`
+	RecordName           sql.NullString `nmap:"skip"`
+	RecordType           sql.NullString `nmap:"skip"`
+	RecordClass          sql.NullString `nmap:"skip"`
+	RecordTTL            sql.NullInt64  `nmap:"skip"`
+	RecordRData          sql.NullString `nmap:"skip"`
+}
+
+type NameserverGroup struct {
+	ID                   string
+	PublicID             sql.NullString
+	Name                 sql.NullString
+	Description          sql.NullString
+	NameServers          []byte `nmap:"json"`
+	Groups               []byte `nmap:"json"`
+	Primary              sql.NullBool
+	Domains              []byte `nmap:"json"`
+	Enabled              sql.NullBool
+	SearchDomainsEnabled sql.NullBool
+}
+
+type Networkresource struct {
+	ID          string
+	NetworkID   sql.NullString
+	AccountID   sql.NullString
+	PublicID    sql.NullString
+	Name        sql.NullString
+	Description sql.NullString
+	Type        sql.NullString
+	Domain      sql.NullString
+	Prefix      []byte `nmap:"json"`
+	Enabled     sql.NullBool
+}
+
+type AccountNetwork struct {
+	Identifier sql.NullString
+	Net        []byte `nmap:"json"`
+	NetV6      []byte `nmap:"json"`
+	Dns        sql.NullString
+	Serial     sql.NullInt64
+}
+
+type Network struct {
+	ID       string
+	PublicID sql.NullString
+}
+
+type Policy struct {
+	ID                  string
+	PublicID            sql.NullString
+	Enabled             sql.NullBool
+	SourcePostureChecks []byte         `nmap:"json"`
+	RuleEnabled         sql.NullBool   `nmap:"skip"`
+	Action              sql.NullString `nmap:"skip"`
+	Protocol            sql.NullString `nmap:"skip"`
+	Bidirectional       sql.NullBool   `nmap:"skip"`
+	Sources             []byte         `nmap:"skip,json"`
+	Destinations        []byte         `nmap:"skip,json"`
+	SourceResource      []byte         `nmap:"skip,json"`
+	DestinationResource []byte         `nmap:"skip,json"`
+	Ports               []byte         `nmap:"skip,json"`
+	PortRanges          []byte         `nmap:"skip,json"`
+	AuthorizedGroups    []byte         `nmap:"skip,json"`
+	AuthorizedUser      sql.NullString `nmap:"skip"`
+}
+
+// Depending on db interface LastLogin contains time in different formats:
+// for sqlite/sql.NullTime the time in UTC
+// for pgx the time is in the local timezone
+// TODO add support for creating struct fields from denormalized fields
+type Peer struct {
+	ID                         string
+	Key                        sql.NullString
+	SSHKey                     sql.NullString
+	DNSLabel                   sql.NullString
+	ExtraDNSLabels             []byte `nmap:"json"`
+	UserID                     sql.NullString
+	SSHEnabled                 sql.NullBool
+	LoginExpirationEnabled     sql.NullBool
+	LastLogin                  sql.NullTime
+	IP                         []byte         `nmap:"json"`
+	IPv6                       []byte         `nmap:"json"`
+	PeerStatusRequiresApproval sql.NullBool   `nmap:"map_to:RequiresApproval"`
+	PeerStatusConnected        sql.NullBool   `nmap:"skip"`
+	ProxyMetaEmbedded          sql.NullBool   `nmap:"skip"`
+	ProxyMetaCluster           sql.NullString `nmap:"skip"`
+	MetaWtVersion              sql.NullString `nmap:"skip"`
+	MetaGoOS                   sql.NullString `nmap:"skip"`
+	MetaOSVersion              sql.NullString `nmap:"skip"`
+	MetaKernelVersion          sql.NullString `nmap:"skip"`
+	MetaNetworkAddresses       []byte         `nmap:"skip,json"`
+	MetaFiles                  []byte         `nmap:"skip,json"`
+	MetaCapabilities           []byte         `nmap:"skip,json"`
+	MetaFlags                  []byte         `nmap:"skip,json"`
+	MetaSyncMessageVersion     sql.NullInt64  `nmap:"skip"`
+	LocationCountryCode        sql.NullString `nmap:"skip"`
+	LocationCityName           sql.NullString `nmap:"skip"`
+	LocationConnectionIp       []byte         `nmap:"skip,json"`
+}
+
+type PostureChecks struct {
+	ID       string
+	PublicID sql.NullString `nmap:"skip"`
+	Checks   []byte         `nmap:"json"`
+}
+
+type Route struct {
+	ID                  string
+	AccountID           sql.NullString
+	PublicID            sql.NullString
+	Network             []byte `nmap:"json"`
+	Domains             []byte `nmap:"json"`
+	KeepRoute           sql.NullBool
+	NetID               sql.NullString
+	Description         sql.NullString
+	Peer                sql.NullString
+	PeerID              sql.NullString
+	PeerGroups          []byte `nmap:"json"`
+	NetworkType         sql.NullInt64
+	Masquerade          sql.NullBool
+	Metric              sql.NullInt64
+	Enabled             sql.NullBool
+	Groups              []byte `nmap:"json"`
+	AccessControlGroups []byte `nmap:"json"`
+	SkipAutoApply       sql.NullBool
+}
+
+func RecordTypeAndRdata(t, rdata string) (int, string, error) {
+	switch t {
+	case "A":
+		return int(dns.TypeA), rdata, nil
+	case "AAAA":
+		return int(dns.TypeAAAA), rdata, nil
+	case "CNAME":
+		return int(dns.TypeCNAME), dns.Fqdn(rdata), nil
+	default:
+		return 0, "", fmt.Errorf("record type: %s %w", t, ErrDnsUnsupportedRecordType)
+	}
+}
+
+func ZonesToAppliedZoneCandidates(zones []Zone) ([]networkmap.AppliedZoneCandidate, error) {
+	toret := make([]networkmap.AppliedZoneCandidate, 0, len(zones))
+	currentZoneId := ""
+	for _, z := range zones {
+		if !z.RecordType.Valid {
+			continue
+		}
+
+		zone := nmdata.CustomZone{}
+		err := FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&z), reflect.ValueOf(&zone))
+		if err != nil {
+			return nil, err
+		}
+
+		var distributionGroups []string
+		if err := json.Unmarshal(z.DistributionGroups, &distributionGroups); err != nil {
+			return nil, err
+		}
+
+		if z.Id != currentZoneId {
+			// The account-side builder (types.buildAppliedZoneCandidates) states
+			// the shape of an applied zone: names fully qualified, served
+			// non-authoritatively. Both builders feed the same client-facing map,
+			// so this one has to produce the same value.
+			zone.Domain = dns.Fqdn(zone.Domain)
+			zone.NonAuthoritative = true
+			zone.Records = []nmdata.SimpleRecord{}
+			toret = append(toret, AppliedZoneCandidateFromZone(zone, distributionGroups))
+			currentZoneId = z.Id
+		}
+
+		rtype, rdata, err := RecordTypeAndRdata(z.RecordType.String, z.RecordRData.String)
+		if err != nil {
+			if errors.Is(err, ErrDnsUnsupportedRecordType) {
+				continue
+			}
+			return nil, err
+		}
+
+		lastZone := &toret[len(toret)-1]
+		lastZone.Zone.Records = append(lastZone.Zone.Records, nmdata.SimpleRecord{
+			Name:  dns.Fqdn(z.RecordName.String),
+			Class: z.RecordClass.String,
+			TTL:   int(z.RecordTTL.Int64),
+			RData: rdata,
+			Type:  rtype,
+		})
+	}
+	return toret, nil
+}
+
+func AppliedZoneCandidateFromZone(z nmdata.CustomZone, distributionGroups []string) networkmap.AppliedZoneCandidate {
+	return networkmap.AppliedZoneCandidate{
+		DistributionGroups: distributionGroups,
+		Zone:               z,
+	}
+}
+
+func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
+	toret := make([]nmdata.Peer, 0, len(peers))
+	clusterToPeerIdx := make(map[string][]*nmdata.Peer)
+	for _, p := range peers {
+		dp := nmdata.Peer{}
+		err := FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&p), reflect.ValueOf(&dp))
+		if err != nil {
+			return nil, nil, err
+		}
+
+		if p.ProxyMetaEmbedded.Valid {
+			dp.ProxyMeta.Embedded = p.ProxyMetaEmbedded.Bool
+		}
+		dp.ProxyMeta.Cluster = p.ProxyMetaCluster.String
+		// This is only used to build private service candidates, not connected peers are skipped
+		if dp.ProxyMeta.Embedded && p.PeerStatusConnected.Bool {
+			clusterToPeerIdx[p.ProxyMetaCluster.String] = append(clusterToPeerIdx[p.ProxyMetaCluster.String], &dp)
+		}
+		if p.MetaWtVersion.Valid {
+			dp.Meta.WtVersion = p.MetaWtVersion.String
+		}
+		if p.MetaSyncMessageVersion.Valid {
+			dp.Meta.SyncMessageVersion = int(p.MetaSyncMessageVersion.Int64)
+		}
+		if p.MetaGoOS.Valid {
+			dp.Meta.GoOS = p.MetaGoOS.String
+		}
+		if p.MetaOSVersion.Valid {
+			dp.Meta.OSVersion = p.MetaOSVersion.String
+		}
+		if p.MetaKernelVersion.Valid {
+			dp.Meta.KernelVersion = p.MetaKernelVersion.String
+		}
+		if p.LocationCountryCode.Valid {
+			dp.Location.CountryCode = p.LocationCountryCode.String
+		}
+		if p.LocationCityName.Valid {
+			dp.Location.CityName = p.LocationCityName.String
+		}
+		if p.LocationConnectionIp != nil {
+			err := json.Unmarshal(p.LocationConnectionIp, &dp.Location.ConnectionIP)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+		if p.MetaFiles != nil {
+			err := json.Unmarshal(p.MetaFiles, &dp.Meta.Files)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+		if p.MetaCapabilities != nil {
+			err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+		if p.MetaFlags != nil {
+			err := json.Unmarshal(p.MetaFlags, &dp.Meta.Flags)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+		if p.MetaNetworkAddresses != nil {
+			err := json.Unmarshal(p.MetaNetworkAddresses, &dp.Meta.NetworkAddresses)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+
+		toret = append(toret, dp)
+	}
+
+	return toret, clusterToPeerIdx, nil
+}
+
+func ConvertToNmdataPolicy(policies []Policy) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
+	toret := make([]nmdata.Policy, 0, len(policies))
+	policyToDestinationResourceIdx := make(map[string]map[string]any) // policy id to destination resource id
+	policyToDestinationGroupIdx := make(map[string]map[string]any)    // policy id to destination group id
+	for _, p := range policies {
+		policy := nmdata.Policy{}
+		err := FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&p), reflect.ValueOf(&policy))
+		if err != nil {
+			return nil, nil, nil, err
+		}
+
+		var policyRule *nmdata.PolicyRule
+		pr := func() *nmdata.PolicyRule {
+			if policyRule != nil {
+				return policyRule
+			}
+
+			policyRule = &nmdata.PolicyRule{}
+			return policyRule
+		}
+
+		if p.RuleEnabled.Valid {
+			pr().Enabled = p.RuleEnabled.Bool
+		}
+		if p.Action.Valid {
+			pr().Action = p.Action.String
+		}
+		if p.Protocol.Valid {
+			pr().Protocol = p.Protocol.String
+		}
+		if p.Bidirectional.Valid {
+			pr().Bidirectional = p.Bidirectional.Bool
+		}
+		if len(p.Sources) > 0 {
+			err := json.Unmarshal([]byte(p.Sources), &pr().Sources)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if len(p.Destinations) > 0 {
+			err := json.Unmarshal([]byte(p.Destinations), &pr().Destinations)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+
+			if p.RuleEnabled.Valid && p.RuleEnabled.Bool {
+				for _, dst := range pr().Destinations {
+					if _, ok := policyToDestinationGroupIdx[p.ID]; !ok {
+						policyToDestinationGroupIdx[p.ID] = make(map[string]any)
+					}
+					policyToDestinationGroupIdx[p.ID][dst] = struct{}{}
+				}
+			}
+		}
+		if len(p.SourceResource) > 0 {
+			err := json.Unmarshal([]byte(p.SourceResource), &pr().SourceResource)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if len(p.DestinationResource) > 0 {
+			err := json.Unmarshal([]byte(p.DestinationResource), &pr().DestinationResource)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+
+			if p.RuleEnabled.Valid && p.RuleEnabled.Bool {
+				if _, ok := policyToDestinationResourceIdx[p.ID]; !ok {
+					policyToDestinationResourceIdx[p.ID] = make(map[string]any)
+				}
+				policyToDestinationResourceIdx[p.ID][pr().DestinationResource.ID] = struct{}{}
+			}
+		}
+		if len(p.Ports) > 0 {
+			err := json.Unmarshal([]byte(p.Ports), &pr().Ports)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if len(p.PortRanges) > 0 {
+			err := json.Unmarshal([]byte(p.PortRanges), &pr().PortRanges)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if len(p.AuthorizedGroups) > 0 {
+			err := json.Unmarshal([]byte(p.AuthorizedGroups), &pr().AuthorizedGroups)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if p.AuthorizedUser.Valid {
+			pr().AuthorizedUser = p.AuthorizedUser.String
+		}
+
+		if policyRule != nil {
+			policyRule.ID = p.ID
+			policyRule.PolicyID = p.ID
+			policy.Rules = []*nmdata.PolicyRule{policyRule}
+		}
+
+		toret = append(toret, policy)
+	}
+
+	return toret, policyToDestinationResourceIdx, policyToDestinationGroupIdx, nil
+}
diff --git a/management/internals/network_map_db/shared_types_test.go b/management/internals/network_map_db/shared_types_test.go
new file mode 100644
index 000000000..8a1239e26
--- /dev/null
+++ b/management/internals/network_map_db/shared_types_test.go
@@ -0,0 +1,38 @@
+package networkmapdb
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRecordTypeAndRdata(t *testing.T) {
+	var tests = []struct {
+		recordType         string
+		expectedRecordType int
+		rdata              string
+		expectedRdata      string
+		expectedErr        error
+	}{
+		{recordType: "A", expectedRecordType: 1, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil},
+		{recordType: "AAAA", expectedRecordType: 28, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil},
+		{recordType: "CNAME", expectedRecordType: 5, rdata: "test.com", expectedRdata: "test.com.", expectedErr: nil},
+		{recordType: "CNAME", expectedRecordType: 5, rdata: "test.com.", expectedRdata: "test.com.", expectedErr: nil},
+		{recordType: "TypeMX", expectedErr: ErrDnsUnsupportedRecordType},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.recordType, func(t *testing.T) {
+			recordType, rdata, err := RecordTypeAndRdata(tt.recordType, tt.rdata)
+
+			if tt.expectedErr != nil {
+				assert.ErrorIs(t, err, ErrDnsUnsupportedRecordType)
+				return
+			}
+
+			assert.NoError(t, err)
+			assert.Equal(t, recordType, tt.expectedRecordType)
+			assert.Equal(t, rdata, tt.expectedRdata)
+		})
+	}
+}
diff --git a/management/internals/network_map_db/sql_type_conversion_test.go b/management/internals/network_map_db/sql_type_conversion_test.go
new file mode 100644
index 000000000..77dab93a8
--- /dev/null
+++ b/management/internals/network_map_db/sql_type_conversion_test.go
@@ -0,0 +1,253 @@
+package networkmapdb
+
+import (
+	"database/sql"
+	"encoding/json"
+	"reflect"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNullStringSupport(t *testing.T) {
+	src := withNullString{Name: sql.NullString{String: "string", Valid: true}}
+	dst := withString{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withString{Name: "string"}, dst)
+
+	src = withNullString{Name: sql.NullString{Valid: false}}
+	dst = withString{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withString{Name: ""}, dst)
+}
+
+func TestNullBoolSupport(t *testing.T) {
+	src := withNullBool{TrueOrFalse: sql.NullBool{Bool: true, Valid: true}}
+	dst := withBool{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withBool{TrueOrFalse: true}, dst)
+
+}
+
+func TestRawJsonSupport(t *testing.T) {
+	jb, _ := json.Marshal(embeddedS{Name: "blob-name", SomeField: 1})
+	src := withRawJson{Blob: json.RawMessage(jb)}
+	dst := fromJson{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, fromJson{Blob: embeddedS{Name: "blob-name", SomeField: 1}}, dst)
+
+	src1 := withRawJson{}
+	dst1 := fromJson{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src1), reflect.ValueOf(&dst1)))
+	assert.Equal(t, fromJson{}, dst1)
+}
+
+func TestShouldSkipTag(t *testing.T) {
+	src5 := withSkipTag{Field: "shouldskip"}
+	dst5 := emptySkipTagTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src5), reflect.ValueOf(&dst5)))
+	assert.Equal(t, emptySkipTagTarget{}, dst5)
+
+}
+
+func TestMapToTag(t *testing.T) {
+	src6 := withMapToTag{Field: "fieldvalue"}
+	dst6 := mapToTagTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src6), reflect.ValueOf(&dst6)))
+	assert.Equal(t, mapToTagTarget{AnotherField: "fieldvalue"}, dst6)
+}
+
+func TestNullableInt64Support(t *testing.T) {
+	src := withInt64{Field: sql.NullInt64{Int64: int64(1), Valid: true}}
+	dst := int64Target{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, int64Target{Field: 1}, dst)
+}
+
+func TestNullableTimeSupport(t *testing.T) {
+	now := time.Now()
+	src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}}
+	dst := nullableTimeTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, nullableTimeTarget{Field: now}, dst)
+}
+
+func TestNullableTimePointerSupport(t *testing.T) {
+	now := time.Now()
+	src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}}
+	dst := nullableTimePointerTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, nullableTimePointerTarget{Field: &now}, dst)
+}
+
+func TestStringSLiceSupport(t *testing.T) {
+	src := withStringSlice{Field: []string{"one"}}
+	dst := withStringSlice{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withStringSlice{Field: []string{"one"}}, dst)
+}
+
+func TestNullStringSLiceSupport(t *testing.T) {
+	src := withStringSlice{}
+	dst := withStringSlice{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withStringSlice{}, dst)
+}
+
+func TestWithMultipleFields(t *testing.T) {
+	now := time.Now()
+	src := withMultipleFields{
+		Field1: sql.NullString{String: "aaa", Valid: true},
+		Field2: sql.NullBool{Bool: true, Valid: true},
+		Field3: sql.NullTime{Time: now, Valid: true},
+		Field4: sql.NullInt64{Int64: 1, Valid: true},
+		Field5: "another",
+	}
+	dst := multipleFieldsTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, multipleFieldsTarget{
+		Field1: "aaa",
+		Field2: true,
+		Field3: now,
+		Field4: 1,
+		Field5: "another",
+	}, dst)
+}
+
+func TestEmptyPublicIdsFilled(t *testing.T) {
+	src := withEmptyPublicIds{}
+	dst := emptyPublicIdTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.NotEmpty(t, dst.PublicID)
+	assert.NotEmpty(t, dst.PublicId)
+}
+
+// only []byte and []uint8 slices with "json" tag are being parsed
+func TestByteSliceSupport(t *testing.T) {
+	src := withByteSlice{
+		Field: []byte("[\"one\",\"two\",\"three\"]"),
+	}
+	dst := byteSliceTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, []string{"one", "two", "three"}, dst.Field)
+}
+
+func TestUint8SliceSupport(t *testing.T) {
+	src := withUint8Slice{
+		Field: []uint8("[\"one\",\"two\",\"three\"]"),
+	}
+	dst := uint8SliceTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, []string{"one", "two", "three"}, dst.Field)
+}
+
+type withNullString struct {
+	Name sql.NullString
+}
+
+type withString struct {
+	Name string
+}
+
+type withMultipleFields struct {
+	Field1 sql.NullString
+	Field2 sql.NullBool
+	Field3 sql.NullTime
+	Field4 sql.NullInt64
+	Field5 string
+}
+
+type multipleFieldsTarget struct {
+	Field1 string
+	Field2 bool
+	Field3 time.Time
+	Field4 int64
+	Field5 string
+}
+
+type withNullBool struct {
+	TrueOrFalse sql.NullBool
+}
+
+type withBool struct {
+	TrueOrFalse bool
+}
+
+type withRawJson struct {
+	Blob json.RawMessage
+}
+
+type embeddedS struct {
+	Name      string
+	SomeField int
+}
+type fromJson struct {
+	Blob embeddedS
+}
+
+type withSkipTag struct {
+	Field string `nmap:"skip"`
+}
+
+type emptySkipTagTarget struct {
+	Field string
+}
+
+type withMapToTag struct {
+	Field string `nmap:"map_to:AnotherField"`
+}
+
+type mapToTagTarget struct {
+	AnotherField string
+}
+
+type withInt64 struct {
+	Field sql.NullInt64
+}
+
+type int64Target struct {
+	Field int
+}
+
+type withNullableTime struct {
+	Field sql.NullTime
+}
+
+type nullableTimeTarget struct {
+	Field time.Time
+}
+
+type nullableTimePointerTarget struct {
+	Field *time.Time
+}
+
+type withStringSlice struct {
+	Field []string
+}
+
+type withEmptyPublicIds struct {
+	PublicID sql.NullString
+	PublicId sql.NullString
+}
+
+type emptyPublicIdTarget struct {
+	PublicID string
+	PublicId string
+}
+
+type withByteSlice struct {
+	Field []byte `nmap:"json"`
+}
+
+type byteSliceTarget struct {
+	Field []string
+}
+
+type withUint8Slice struct {
+	Field []byte `nmap:"json"`
+}
+
+type uint8SliceTarget struct {
+	Field []string
+}
diff --git a/management/internals/network_map_db/sqlite/account_setting.go b/management/internals/network_map_db/sqlite/account_setting.go
new file mode 100644
index 000000000..9a1a152fe
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/account_setting.go
@@ -0,0 +1,47 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetAccountSettingsQuery = `
+	select settings_peer_login_expiration_enabled as peer_login_expiration_enabled,
+	settings_peer_login_expiration as peer_login_expiration,
+	settings_peer_inactivity_expiration_enabled as peer_inactivity_expiration_enabled,
+	settings_peer_inactivity_expiration as peer_inactivity_expiration,
+	settings_dns_domain as dns_domain,
+	settings_ipv6_enabled_groups as ipv6_enabled_groups,
+	settings_routing_peer_dns_resolution_enabled as routing_peer_dns_resolution_enabled,
+	settings_lazy_connection_enabled as lazy_connection_enabled,
+	settings_auto_update_version as auto_update_version,
+	settings_auto_update_always as auto_update_always,
+	settings_metrics_push_enabled as metrics_push_enabled
+	from accounts
+	where id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetAccountSettingsQuery, accountId)
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	a, err := CollectOneRowForSqlite[networkmapdb.Account](rows)
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	settingsInfo := nmdata.AccountSettingsInfo{}
+	err = networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&a), reflect.ValueOf(&settingsInfo))
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	return settingsInfo, nil
+}
diff --git a/management/internals/network_map_db/sqlite/dns.go b/management/internals/network_map_db/sqlite/dns.go
new file mode 100644
index 000000000..dd2cb3758
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/dns.go
@@ -0,0 +1,32 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+)
+
+const (
+	GetAccountZonesQuery = `
+	select zones.id as id, domain, not enable_search_domain as search_domain_disabled, distribution_groups,
+	r.name as record_name, r.type as record_type, 'IN' record_class, r.ttl as record_ttl, r.content as record_rdata
+	from zones
+	left join records as r on r.zone_id = zones.id
+	where zones.account_id=? and zones.enabled
+	`
+)
+
+func (sc *SqliteStoreConn) GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetAccountZonesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	zones, err := CollectRowsForSqlite[networkmapdb.Zone](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ZonesToAppliedZoneCandidates(zones)
+}
diff --git a/management/internals/network_map_db/sqlite/dns_setting.go b/management/internals/network_map_db/sqlite/dns_setting.go
new file mode 100644
index 000000000..7c6e9e7eb
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/dns_setting.go
@@ -0,0 +1,42 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"encoding/json"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetDnsSettingsQuery = `
+	select dns_settings_disabled_management_groups
+	from accounts
+	where id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetDnsSettingsQuery, accountId)
+	if err != nil {
+		return nmdata.DNSSettings{}, err
+	}
+	defer rows.Close()
+
+	var value nmdata.DNSSettings
+	var settings []byte
+
+	rows.Next()
+	if err := rows.Scan(&settings); err != nil {
+		return value, err
+	}
+
+	if settings == nil {
+		return nmdata.DNSSettings{}, nil
+	}
+
+	if err := json.Unmarshal(settings, &value.DisabledManagementGroups); err != nil {
+		return value, err
+	}
+
+	return value, nil
+}
diff --git a/management/internals/network_map_db/sqlite/domain.go b/management/internals/network_map_db/sqlite/domain.go
new file mode 100644
index 000000000..572977c3b
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/domain.go
@@ -0,0 +1,24 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetDomainsQuery = `
+	select domain, target_cluster
+	from domains
+	where account_id=? and domain<>'' and target_cluster<>''
+	`
+)
+
+func (sc *SqliteStoreConn) GetDomains(ctx context.Context, accountId string) ([]networkmapdb.Domain, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetDomainsQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	return CollectRowsForSqlite[networkmapdb.Domain](rows)
+}
diff --git a/management/internals/network_map_db/sqlite/group.go b/management/internals/network_map_db/sqlite/group.go
new file mode 100644
index 000000000..c324d0d29
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/group.go
@@ -0,0 +1,70 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"database/sql"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetGroupsQuery = `
+	select groups.id, groups.name, groups.public_id, groups.resources, gp.peer_id
+	from groups 
+	left join group_peers gp on gp.group_id=groups.id and gp.account_id=?
+	where groups.account_id=?
+	`
+)
+
+// we also return a resource-to-group index.
+// an alternative is to add json indexes, query this directly. Not sure how expensive
+// json indexes are. TODO (dmitri) verify and maybe change the implementation here.
+func (sc *SqliteStoreConn) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetGroupsQuery, accountId, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	groups, err := CollectRowsForSqlite[group](rows)
+
+	toret := make([]nmdata.Group, 0, len(groups))
+	resourceToGroupIdx := make(map[string]map[string]any)
+
+	for _, g := range groups {
+		if len(toret) > 0 && toret[len(toret)-1].ID == g.ID && g.PeerID.Valid {
+			toret[len(toret)-1].Peers = append(toret[len(toret)-1].Peers, g.PeerID.String)
+			continue
+		}
+
+		dg := nmdata.Group{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&g), reflect.ValueOf(&dg))
+		if err != nil {
+			return nil, nil, err
+		}
+
+		if g.PeerID.Valid {
+			dg.Peers = append(dg.Peers, g.PeerID.String)
+		}
+		toret = append(toret, dg)
+
+		for _, resource := range dg.Resources {
+			if _, ok := resourceToGroupIdx[resource.ID]; !ok {
+				resourceToGroupIdx[resource.ID] = make(map[string]any)
+			}
+			resourceToGroupIdx[resource.ID][g.ID] = struct{}{}
+		}
+	}
+
+	return toret, resourceToGroupIdx, err
+}
+
+type group struct {
+	ID        string
+	Name      sql.NullString
+	PublicID  sql.NullString
+	Resources []byte         `nmap:"json"`
+	PeerID    sql.NullString `nmap:"skip"`
+}
diff --git a/management/internals/network_map_db/sqlite/nameserver.go b/management/internals/network_map_db/sqlite/nameserver.go
new file mode 100644
index 000000000..618e1a1f3
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/nameserver.go
@@ -0,0 +1,30 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNameserversQuery = `
+	select id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled
+	from name_server_groups
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNameserversQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	nsgroups, err := CollectRowsForSqlite[networkmapdb.NameserverGroup](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.NameserverGroup, nmdata.NameServerGroup](nsgroups)
+}
diff --git a/management/internals/network_map_db/sqlite/network.go b/management/internals/network_map_db/sqlite/network.go
new file mode 100644
index 000000000..3fa85ecdf
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/network.go
@@ -0,0 +1,38 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkQuery = `
+	select network_identifier as identifier, network_net as net, network_net_v6 as net_v6, network_dns as dns, network_serial as serial
+	from accounts
+	where id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNetworkQuery, accountId)
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	n, err := CollectOneRowForSqlite[networkmapdb.AccountNetwork](rows)
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	toret := nmdata.Network{}
+	err = networkmapdb.FromSqlTypesToSharedTypes(
+		reflect.ValueOf(&n), reflect.ValueOf(&toret))
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/sqlite/network_resource.go b/management/internals/network_map_db/sqlite/network_resource.go
new file mode 100644
index 000000000..1d98a12e9
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/network_resource.go
@@ -0,0 +1,30 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkResourcesQuery = `
+	select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled
+	from network_resources
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNetworkResourcesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	netresorces, err := CollectRowsForSqlite[networkmapdb.Networkresource](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Networkresource, nmdata.NetworkResource](netresorces)
+}
diff --git a/management/internals/network_map_db/sqlite/network_router.go b/management/internals/network_map_db/sqlite/network_router.go
new file mode 100644
index 000000000..8c4c31cd6
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/network_router.go
@@ -0,0 +1,74 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkRouterQuery = `
+	select public_id, peer, network_id, masquerade, metric, enabled, peer_groups, group_peers.peer_id
+	from network_routers, json_each(peer_groups)
+	left join group_peers on group_peers.account_id=? and group_peers.group_id=json_each.value
+	where network_routers.account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNetworkRouterQuery, accountId, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	routers, err := CollectRowsForSqlite[networkrouter](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]map[string]*nmdata.NetworkRouter)
+	for _, router := range routers {
+		if !router.Enabled.Bool {
+			continue
+		}
+
+		networkId := router.NetworkID.String
+		if networkId == "" {
+			return nil, fmt.Errorf("router with public_id %s doesn't have network_id set", router.PublicID.String)
+		}
+
+		nmdatarouter := nmdata.NetworkRouter{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&router), reflect.ValueOf(&nmdatarouter))
+		if err != nil {
+			return nil, err
+		}
+
+		if toret[networkId] == nil {
+			toret[networkId] = make(map[string]*nmdata.NetworkRouter)
+		}
+		if router.Peer.String != "" {
+			toret[networkId][router.Peer.String] = &nmdatarouter
+			continue
+		}
+		if router.PeerViaGroups.String != "" {
+			toret[networkId][router.PeerViaGroups.String] = &nmdatarouter
+		}
+	}
+
+	return toret, nil
+}
+
+type networkrouter struct {
+	PublicID      sql.NullString
+	Peer          sql.NullString `nmap:"skip"`
+	NetworkID     sql.NullString `nmap:"skip"`
+	Masquerade    sql.NullBool
+	Metric        sql.NullInt64
+	Enabled       sql.NullBool
+	PeerGroups    []byte         `nmap:"json"`
+	PeerViaGroups sql.NullString `nmap:"skip"`
+}
diff --git a/management/internals/network_map_db/sqlite/networks.go b/management/internals/network_map_db/sqlite/networks.go
new file mode 100644
index 000000000..e19336846
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/networks.go
@@ -0,0 +1,35 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetNetworksQuery = `
+	select id, public_id
+	from networks where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNetworksQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	networks, err := CollectRowsForSqlite[networkmapdb.Network](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]string)
+	for _, n := range networks {
+		if n.PublicID.Valid {
+			toret[n.ID] = n.PublicID.String
+		}
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/sqlite/peer.go b/management/internals/network_map_db/sqlite/peer.go
new file mode 100644
index 000000000..12d9e9ab7
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/peer.go
@@ -0,0 +1,33 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPeersQuery = `
+	select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
+	peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
+	meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
+	location_country_code, location_city_name, location_connection_ip
+	from peers
+	where account_id = ?
+	`
+)
+
+func (sc *SqliteStoreConn) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetPeersQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	peers, err := CollectRowsForSqlite[networkmapdb.Peer](rows)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return networkmapdb.ConvertToNmdataPeers(peers)
+}
diff --git a/management/internals/network_map_db/sqlite/policy.go b/management/internals/network_map_db/sqlite/policy.go
new file mode 100644
index 000000000..1a11f6e20
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/policy.go
@@ -0,0 +1,33 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPoliciesQuery = `
+	select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled as rule_enabled, pr.action, pr.protocol, pr.bidirectional, 
+	pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges,
+	pr.authorized_groups, pr.authorized_user
+	from policies as p
+	left join policy_rules as pr on p.id = pr.policy_id 
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetPoliciesQuery, accountId)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	policies, err := CollectRowsForSqlite[networkmapdb.Policy](rows)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	return networkmapdb.ConvertToNmdataPolicy(policies)
+}
diff --git a/management/internals/network_map_db/sqlite/posture.go b/management/internals/network_map_db/sqlite/posture.go
new file mode 100644
index 000000000..6caee6e79
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/posture.go
@@ -0,0 +1,43 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPostureChecksQuery = `
+	select id, public_id, checks
+	from posture_checks
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetPostureChecksQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	checks, err := CollectRowsForSqlite[networkmapdb.PostureChecks](rows)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	toret := make([]nmdata.PostureChecks, 0, len(checks))
+	idToPublicIDIdx := make(map[string]string)
+	for _, c := range checks {
+		checks := nmdata.PostureChecks{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&c), reflect.ValueOf(&checks))
+		if err != nil {
+			return nil, nil, err
+		}
+		toret = append(toret, checks)
+		idToPublicIDIdx[checks.ID] = c.PublicID.String
+	}
+
+	return toret, idToPublicIDIdx, nil
+}
diff --git a/management/internals/network_map_db/sqlite/route.go b/management/internals/network_map_db/sqlite/route.go
new file mode 100644
index 000000000..58b3eca55
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/route.go
@@ -0,0 +1,32 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetRoutesQuery = `
+	select id, account_id, public_id, network, domains, keep_route, net_id, description,
+	peer, peer as peer_id, peer_groups, network_type, masquerade, metric, enabled, 
+	groups, access_control_groups, skip_auto_apply
+	from routes
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetRoutesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	routes, err := CollectRowsForSqlite[networkmapdb.Route](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Route, nmdata.Route](routes)
+}
diff --git a/management/internals/network_map_db/sqlite/service.go b/management/internals/network_map_db/sqlite/service.go
new file mode 100644
index 000000000..5d25f69e5
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/service.go
@@ -0,0 +1,89 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetServicesQuery = `
+	select enabled, private, access_groups, proxy_cluster, domain
+	from services
+	where account_id=?
+	`
+
+	GetProxyTargetedDomainResourcesQuery = `
+	select t.target_id
+	from targets as t
+	join services as s on s.id = t.service_id
+	where s.account_id=? and s.enabled and not coalesce(s.terminated, false)
+	and t.enabled and t.target_type='domain' and t.target_id is not null
+	`
+)
+
+func (sc *SqliteStoreConn) GetPrivateServices(ctx context.Context, accountId string) ([]networkmapdb.Service, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetServicesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	services, err := CollectRowsForSqlite[service](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make([]networkmapdb.Service, 0, len(services))
+	for _, service := range services {
+		acg := []string{}
+		if service.AccessGroups != nil {
+			if err := json.Unmarshal(service.AccessGroups, &acg); err != nil {
+				return nil, err
+			}
+		}
+		s := networkmapdb.Service{
+			Enabled:      service.Enabled,
+			Private:      service.Private,
+			AccessGroups: acg,
+			ProxyCluster: service.ProxyCluster,
+			Domain:       service.Domain,
+		}
+
+		toret = append(toret, s)
+	}
+	return toret, nil
+}
+
+func (sc *SqliteStoreConn) GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetProxyTargetedDomainResourcesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	toret := make(map[string]struct{})
+	for rows.Next() {
+		var id string
+		err := rows.Scan(&id)
+		if err != nil {
+			return nil, err
+		}
+		toret[id] = struct{}{}
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return toret, nil
+}
+
+type service struct {
+	Enabled      sql.NullBool
+	Private      sql.NullBool
+	AccessGroups []byte
+	ProxyCluster sql.NullString
+	Domain       sql.NullString
+}
diff --git a/management/internals/network_map_db/sqlite/sqlite_store.go b/management/internals/network_map_db/sqlite/sqlite_store.go
new file mode 100644
index 000000000..14abf80bc
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/sqlite_store.go
@@ -0,0 +1,148 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+	"os"
+	"path/filepath"
+	"reflect"
+	"runtime"
+	"strings"
+
+	"database/sql"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+var ErrNoRows = errors.New("no rows in result set")
+
+type SqliteStore struct {
+	Db *sql.DB
+}
+
+type sqliteInterface interface {
+	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
+	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+}
+
+type SqliteStoreConn struct {
+	Conn sqliteInterface
+}
+
+func NewSqliteStore(storeFile, dataDir string) (*SqliteStore, error) {
+	dbfile := storeFile
+	if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" {
+		dbfile = envFile
+	}
+
+	// Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc")
+	filePath, query, hasQuery := strings.Cut(dbfile, "?")
+
+	connStr := filePath
+	if filePath != ":memory:" && !filepath.IsAbs(filePath) {
+		connStr = filepath.Join(dataDir, filePath)
+	}
+
+	// Compose query parameters. User-provided ?_busy_timeout (or its mattn alias
+	// ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at
+	// most that long on a lock instead of blocking the only Go-side connection.
+	// mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so
+	// the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared
+	// stays the default on non-Windows for the same reason as before.
+	parsed, _ := url.ParseQuery(query)
+	var defaults []string
+	if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" {
+		defaults = append(defaults, "_busy_timeout=30000")
+	}
+	if !hasQuery && runtime.GOOS != "windows" {
+		// To avoid `The process cannot access the file because it is being used by another process` on Windows
+		defaults = append(defaults, "cache=shared")
+	}
+	parts := defaults
+	if hasQuery {
+		parts = append(parts, query)
+	}
+	if len(parts) > 0 {
+		connStr += "?" + strings.Join(parts, "&")
+	}
+
+	db, err := sql.Open("sqlite3", connStr)
+	if err != nil {
+		return nil, err
+	}
+
+	return &SqliteStore{Db: db}, nil
+}
+
+func (s *SqliteStore) BeginTx(ctx context.Context) (networkmapdb.NetworkMapDBStoreConn, error) {
+	tx, err := s.Db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead})
+	if err != nil {
+		return nil, err
+	}
+	return &SqliteStoreConn{Conn: tx}, nil
+}
+
+func (s *SqliteStore) Exec(_ context.Context, query string, args ...any) error {
+	_, err := s.Db.Exec(query, args...)
+	return err
+}
+
+func (sc *SqliteStoreConn) RollbackTx(ctx context.Context) error {
+	tx, ok := sc.Conn.(*sql.Tx)
+	if !ok {
+		return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(sc.Conn).Kind())
+	}
+	return tx.Rollback()
+}
+
+func (sc *SqliteStoreConn) CommitTx(ctx context.Context) error {
+	tx, ok := sc.Conn.(*sql.Tx)
+	if !ok {
+		return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(sc.Conn).Kind())
+	}
+	return tx.Commit()
+}
+
+func (s *SqliteStore) UsingConn() *SqliteStoreConn {
+	return &SqliteStoreConn{Conn: s.Db}
+}
+
+func CollectOneRowForSqlite[T any](rows *sql.Rows) (T, error) {
+	defer rows.Close()
+	var r T
+
+	if !rows.Next() {
+		if err := rows.Err(); err != nil {
+			return r, err
+		}
+		return r, ErrNoRows
+	}
+	err := rows.Scan(networkmapdb.StructFields(&r)...)
+	if err != nil {
+		return r, err
+	}
+
+	return r, nil
+}
+
+func CollectRowsForSqlite[T any](rows *sql.Rows) ([]T, error) {
+	defer rows.Close()
+	toret := make([]T, 0)
+
+	for rows.Next() {
+		var r T
+		err := rows.Scan(networkmapdb.StructFields(&r)...)
+		if err != nil {
+			return nil, err
+		}
+		toret = append(toret, r)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/sqlite/user.go b/management/internals/network_map_db/sqlite/user.go
new file mode 100644
index 000000000..0bdda372e
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/user.go
@@ -0,0 +1,84 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+)
+
+const (
+	GetAllowedUserIdsQuery = `
+	select id, auto_groups
+	from users
+	where account_id=? and not blocked and not is_service_user
+	`
+
+	GetAllGroupIdQuery = `
+	select id from groups
+	where account_id=? and name='All'
+	`
+)
+
+func (sc *SqliteStoreConn) GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetAllowedUserIdsQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	users, err := CollectRowsForSqlite[user](rows)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	rows, err = sc.Conn.QueryContext(ctx, GetAllGroupIdQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+	allGroupIds, err := collectAllGroupIds(rows)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	userIdIdx := make(map[string]struct{})
+	groupIdToUserIds := make(map[string][]string)
+	for _, user := range users {
+		autogroups := make([]string, 0)
+		if err := json.Unmarshal(user.AutoGroups, &autogroups); err != nil {
+			return nil, nil, err
+		}
+		userIdIdx[user.ID] = struct{}{}
+		for _, groupId := range autogroups {
+			groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID)
+		}
+		for _, allgid := range allGroupIds {
+			groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID)
+		}
+	}
+
+	return userIdIdx, groupIdToUserIds, nil
+}
+
+func collectAllGroupIds(rows *sql.Rows) ([]string, error) {
+	defer rows.Close()
+	var toret []string
+
+	for rows.Next() {
+		var id string
+		err := rows.Scan(&id)
+		if err != nil {
+			return nil, err
+		}
+		toret = append(toret, id)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return toret, nil
+}
+
+type user struct {
+	ID         string
+	AutoGroups []byte
+}
diff --git a/management/internals/network_map_db/struct_helpers.go b/management/internals/network_map_db/struct_helpers.go
new file mode 100644
index 000000000..1719662fd
--- /dev/null
+++ b/management/internals/network_map_db/struct_helpers.go
@@ -0,0 +1,157 @@
+package networkmapdb
+
+import (
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"reflect"
+	"strings"
+
+	"github.com/rs/xid"
+)
+
+var ErrNoRows = errors.New("no rows in result set")
+
+const (
+	NMAP_STRUCT_TAG = "nmap"
+	NMAP_SKIP       = "skip"
+	NMAP_MAP_TO     = "map_to"
+	NMAP_JSON       = "json"
+)
+
+type fieldTag struct {
+	Key   string
+	Value string
+}
+
+func tagFromString(t string) fieldTag {
+	kv := strings.Split(t, ":")
+	if len(kv) == 1 {
+		return fieldTag{Key: strings.TrimSpace(kv[0])}
+	}
+	return fieldTag{Key: strings.TrimSpace(kv[0]), Value: strings.TrimSpace(kv[1])}
+}
+
+func FromSqlTypesToSharedTypes(src reflect.Value, dst reflect.Value) error {
+	typ := src.Elem().Type()
+
+	for i := 0; i < typ.NumField(); i++ {
+		f := typ.Field(i)
+
+		fieldTags := make(map[string]string)
+		if v := f.Tag.Get(NMAP_STRUCT_TAG); v != "" {
+			for _, t := range strings.Split(v, ",") {
+				kv := tagFromString(t)
+				fieldTags[kv.Key] = kv.Value
+			}
+		}
+		if _, ok := fieldTags[NMAP_SKIP]; ok {
+			continue
+		}
+		if f.PkgPath != "" { // skip unexported fields
+			continue
+		}
+		dstFieldName := f.Name
+		if override, ok := fieldTags[NMAP_MAP_TO]; ok {
+			dstFieldName = override
+		}
+
+		dstField := dst.Elem().FieldByName(dstFieldName)
+		if !dstField.IsValid() {
+			return errors.New("unsupported type in destination field: " + dstFieldName)
+		}
+
+		srcField := src.Elem().Field(i)
+		srcFieldType := srcField.Type().String()
+		switch srcFieldType {
+		case "string":
+			s := srcField.Interface().(string)
+			dstField.SetString(s)
+		case "sql.NullString":
+			s := srcField.Interface().(sql.NullString)
+			if s.Valid {
+				dstField.SetString(s.String)
+			}
+			if (dstFieldName == "PublicId" || dstFieldName == "PublicID") && s.String == "" {
+				dstField.SetString(xid.New().String()) // TODO (dmitri) this needs to be removed to support delta updates
+			}
+		case "sql.NullTime":
+			s := srcField.Interface().(sql.NullTime)
+			if s.Valid {
+				if dstField.Kind() == reflect.Ptr {
+					t := reflect.ValueOf(&s.Time).Elem()
+					dstField.Set(t.Addr())
+				} else {
+					dstField.Set(reflect.ValueOf(s.Time))
+				}
+			}
+		case "sql.NullBool":
+			s := srcField.Interface().(sql.NullBool)
+			if s.Valid {
+				dstField.SetBool(s.Bool)
+			}
+		case "sql.NullInt64":
+			s := srcField.Interface().(sql.NullInt64)
+			if s.Valid {
+				dstField.SetInt(s.Int64)
+			}
+		case "json.RawMessage":
+			s := srcField.Interface().(json.RawMessage)
+			if len(s) == 0 {
+				continue
+			}
+			if err := json.Unmarshal(s, dstField.Addr().Interface()); err != nil {
+				return err
+			}
+		case "[]byte", "[]uint8":
+			s := srcField.Interface().([]byte)
+			if _, ok := fieldTags[NMAP_JSON]; !ok || len(s) == 0 {
+				continue
+			}
+			if err := json.Unmarshal(s, dstField.Addr().Interface()); err != nil {
+				return err
+			}
+		case "[]string":
+			if srcField.IsNil() {
+				continue
+			}
+			dstv := reflect.MakeSlice(dstField.Type(), srcField.Len(), srcField.Cap())
+			reflect.Copy(dstv, srcField)
+			dstField.Set(dstv)
+		}
+	}
+
+	return nil
+}
+
+func StructFields(s any) []any {
+	src := reflect.ValueOf(s)
+	toret := make([]any, 0)
+	typ := src.Elem().Type()
+
+	for i := 0; i < typ.NumField(); i++ {
+		f := typ.Field(i)
+		if f.PkgPath != "" { // skip unexported fields
+			continue
+		}
+
+		srcField := src.Elem().Field(i)
+		toret = append(toret, srcField.Addr().Interface())
+	}
+
+	return toret
+}
+
+func ConvertAllToSharedTypes[T any, T1 any](allsrc []T) ([]T1, error) {
+	toret := make([]T1, 0, len(allsrc))
+	for _, src := range allsrc {
+		var dst T1
+		err := FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&src), reflect.ValueOf(&dst))
+		if err != nil {
+			return nil, err
+		}
+		toret = append(toret, dst)
+	}
+	return toret, nil
+}
diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go
index e8f4964c6..0a4df3924 100644
--- a/management/internals/server/boot.go
+++ b/management/internals/server/boot.go
@@ -5,6 +5,7 @@ package server
 import (
 	"context"
 	"crypto/tls"
+	"errors"
 	"net/http"
 	"net/netip"
 	"slices"
@@ -30,6 +31,8 @@ import (
 	proxyactivity "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
 	proxyactivitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager"
 	rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
 	nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
 	"github.com/netbirdio/netbird/management/server/activity"
 	activitystore "github.com/netbirdio/netbird/management/server/activity/store"
@@ -101,6 +104,26 @@ func (s *BaseServer) Store() store.Store {
 	})
 }
 
+// TODO dmitri: move all validation checks (e.g. config+env vars) from runtime to base server creation
+// this way we don't need to spread defensive checks throughout the codebase
+func (s *BaseServer) NetworkMapStore() *networkmapdb.NetworkMapDBStoreImpl {
+	return Create(s, func() *networkmapdb.NetworkMapDBStoreImpl {
+		store, err := networkmapdbfactory.NewNetworkMapDBStore(
+			context.Background(),
+			s.Config.StoreConfig.Engine,
+			s.Config.Datadir,
+			s.IntegratedValidator(),
+			s.SettingsManager())
+		// networkmap db store supports postgres and sqlite backends only
+		// for other backends a fallback is used, so NotSupportedStoreEngineError
+		// is not a fatal error
+		if err != nil && !errors.Is(err, networkmapdbfactory.ErrNotSupportedStoreEngine) {
+			log.Fatalf("failed to create network map store: %v", err)
+		}
+		return store
+	})
+}
+
 func (s *BaseServer) EventStore() activity.Store {
 	return Create(s, func() activity.Store {
 		var err error
diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go
index 1b2556809..a9293d266 100644
--- a/management/internals/server/controllers.go
+++ b/management/internals/server/controllers.go
@@ -123,7 +123,7 @@ func (s *BaseServer) EphemeralManager() ephemeral.Manager {
 
 func (s *BaseServer) NetworkMapController() network_map.Controller {
 	return Create(s, func() network_map.Controller {
-		return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config)
+		return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config, s.NetworkMapStore())
 	})
 }
 
diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go
index baf21af94..a2aad19b6 100644
--- a/management/internals/shared/grpc/components_encoder.go
+++ b/management/internals/shared/grpc/components_encoder.go
@@ -4,10 +4,9 @@ import (
 	"encoding/base64"
 	"strconv"
 
-	nbdns "github.com/netbirdio/netbird/dns"
 	"github.com/netbirdio/netbird/management/server/types"
-	nbroute "github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -84,6 +83,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
 	enc := newComponentEncoder(c)
 	enc.indexAllPeers()
 	routerIdxs := enc.indexRouterPeers(c.RouterPeers)
+	enc.indexAllNetworkResources()
 
 	// Phase 2: gather every policy that any consumer references (peer-pair
 	// policies + resource-only policies) so encodeResourcePoliciesMap can
@@ -105,7 +105,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
 		DnsSettings:         enc.encodeDNSSettings(c.DNSSettings),
 		DnsDomain:           in.DNSDomain,
 		CustomZoneDomain:    c.CustomZoneDomain,
-		AgentVersions:       enc.agentVersions,
 		Peers:               enc.peers,
 		RouterPeerIndexes:   routerIdxs,
 		Policies:            policies,
@@ -130,7 +129,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
 // networkSerial returns c.Network.CurrentSerial() with a nil guard. The
 // production path always populates c.Network, but the encoder is exported
 // and a hand-built components struct may omit it.
-func networkSerial(n *types.Network) uint64 {
+func networkSerial(n *nmdata.Network) uint64 {
 	if n == nil {
 		return 0
 	}
@@ -143,16 +142,15 @@ type componentEncoder struct {
 	peerOrder map[string]uint32
 	peers     []*proto.PeerCompact
 
-	agentVersionOrder map[string]uint32
-	agentVersions     []string
+	networkIdToPublicId map[string]string
 }
 
 func newComponentEncoder(c *types.NetworkMapComponents) *componentEncoder {
 	return &componentEncoder{
-		components:        c,
-		peerOrder:         make(map[string]uint32, len(c.Peers)),
-		peers:             make([]*proto.PeerCompact, 0, len(c.Peers)),
-		agentVersionOrder: make(map[string]uint32),
+		components:          c,
+		peerOrder:           make(map[string]uint32, len(c.Peers)),
+		peers:               make([]*proto.PeerCompact, 0, len(c.Peers)),
+		networkIdToPublicId: make(map[string]string),
 	}
 }
 
@@ -165,7 +163,7 @@ func (e *componentEncoder) indexAllPeers() {
 	}
 }
 
-func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
+func (e *componentEncoder) appendPeer(p *nmdata.Peer) uint32 {
 	if idx, ok := e.peerOrder[p.ID]; ok {
 		return idx
 	}
@@ -175,11 +173,10 @@ func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
 	return idx
 }
 
-// indexRouterPeers ensures every router peer is in the peer dedup table
-// (c.RouterPeers may contain peers not in c.Peers when validation rules drop
-// them) and returns their wire indexes for the RouterPeerIndexes field. Must
-// run before any encoder that resolves peer ids via e.peerOrder.
-func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 {
+// indexRouterPeers ensures every router peer is in the peer dedup table and
+// returns their wire indexes for the RouterPeerIndexes field. Must run before
+// any encoder that resolves peer ids via e.peerOrder.
+func (e *componentEncoder) indexRouterPeers(routers map[string]*nmdata.Peer) []uint32 {
 	if len(routers) == 0 {
 		return nil
 	}
@@ -193,6 +190,15 @@ func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentP
 	return out
 }
 
+func (e *componentEncoder) indexAllNetworkResources() {
+	for _, r := range e.components.NetworkResources {
+		if !r.Enabled {
+			continue
+		}
+		e.networkIdToPublicId[r.ID] = r.PublicID
+	}
+}
+
 func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
 	if len(e.components.Groups) == 0 {
 		return nil
@@ -206,10 +212,22 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
 				peerIdxs = append(peerIdxs, idx)
 			}
 		}
+
+		groupCompactResources := func() []*proto.ResourceCompact {
+			var toret []*proto.ResourceCompact
+			for _, r := range g.Resources {
+				if pr := e.resourceToProto(r); pr != nil {
+					toret = append(toret, pr)
+				}
+			}
+			return toret
+		}
+
 		out = append(out, &proto.GroupCompact{
 			Id:          g.PublicID,
 			PeerIndexes: peerIdxs,
 			IsAll:       g.IsGroupAll(),
+			Resources:   groupCompactResources(),
 		})
 	}
 	return out
@@ -219,7 +237,7 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
 // list and a map from policy pointer to the indexes of its emitted rules in
 // that list — used by encodeResourcePoliciesMap to translate
 // ResourcePoliciesMap[resourceID][]*Policy into wire-side indexes.
-func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.PolicyCompact {
+func (e *componentEncoder) encodePolicies(policies []*nmdata.Policy) []*proto.PolicyCompact {
 	if len(policies) == 0 {
 		return nil
 	}
@@ -241,7 +259,7 @@ func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.Pol
 }
 
 // encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry.
-func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact {
+func (e *componentEncoder) encodePolicyRule(pol *nmdata.Policy, r *nmdata.PolicyRule) *proto.PolicyCompact {
 	return &proto.PolicyCompact{
 		Id:                    pol.PublicID,
 		Action:                networkmap.GetProtoAction(string(r.Action)),
@@ -280,14 +298,14 @@ func (e *componentEncoder) groupPublicXids(src []string) []string {
 // only live in ResourcePoliciesMap; without this union step they'd be lost
 // from the wire and the client's resource-policy lookup would come back
 // empty.
-func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy {
+func unionPolicies(policies []*nmdata.Policy, resourcePolicies map[string][]*nmdata.Policy) []*nmdata.Policy {
 	// Fast path: non-router peers have no resource-only policies, so the
 	// "union" is identical to `policies`. Skip the dedup map allocation.
 	if len(resourcePolicies) == 0 {
 		return policies
 	}
 	seen := make(map[string]struct{}, len(policies))
-	out := make([]*types.Policy, 0, len(policies))
+	out := make([]*nmdata.Policy, 0, len(policies))
 	for _, p := range policies {
 		if p == nil {
 			continue
@@ -314,16 +332,15 @@ func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*type
 }
 
 // encodeAuthorizedGroups translates rule.AuthorizedGroups (map keyed by
-// group xid → local-user names) to the wire form (map keyed by group
-// account_seq_id → UserNameList). Groups without a seq id are dropped —
-// matches how source/destination group references handle the same case.
+// group xid → local-user names) to the wire form (map keyed by
+// authorizedGroupKey → UserNameList).
 func (e *componentEncoder) encodeAuthorizedGroups(m map[string][]string) map[string]*proto.UserNameList {
 	if len(m) == 0 {
 		return nil
 	}
 	out := make(map[string]*proto.UserNameList, len(m))
 	for groupID, names := range m {
-		id, ok := e.groupPublicXid(groupID)
+		id, ok := e.authorizedGroupKey(groupID)
 		if !ok {
 			continue
 		}
@@ -332,6 +349,24 @@ func (e *componentEncoder) encodeAuthorizedGroups(m map[string][]string) map[str
 	return out
 }
 
+// authorizedGroupKey resolves the wire key for a group that grants SSH access.
+// These are user groups: they hold no peers, so nothing puts them in
+// components.Groups and groupPublicXid cannot see them. Dropping them the way a
+// missing source/destination group is dropped would strip every authorized user
+// from the envelope while PeerConfig still reports SSH enabled, leaving the peer
+// running sshd with nobody able to log in — so the id is passed through instead.
+// AuthorizedGroups and GroupIDToUserIDs are only ever used against each other,
+// on both sides of the wire, so they just have to agree.
+func (e *componentEncoder) authorizedGroupKey(groupID string) (string, bool) {
+	if groupID == "" {
+		return "", false
+	}
+	if id, ok := e.groupPublicXid(groupID); ok {
+		return id, true
+	}
+	return groupID, true
+}
+
 func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) {
 	g, ok := e.components.Groups[groupID]
 	if !ok {
@@ -345,17 +380,29 @@ func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) {
 // peers array. For other resource types only the type string is shipped
 // today (Calculate's resource-typed rule path consults SourceResource only
 // for "peer" — other types fall through to group-based lookup).
-func (e *componentEncoder) resourceToProto(r types.Resource) *proto.ResourceCompact {
-	if r.ID == "" && r.Type == "" {
+func (e *componentEncoder) resourceToProto(r nmdata.Resource) *proto.ResourceCompact {
+	if !types.ResourceType(r.Type).Valid() || r.ID == "" {
 		return nil
 	}
-	out := &proto.ResourceCompact{Type: string(r.Type)}
-	if r.Type == types.ResourceTypePeer && r.ID != "" {
-		if idx, ok := e.peerOrder[r.ID]; ok {
-			out.PeerIndexSet = true
-			out.PeerIndex = idx
+
+	out := &proto.ResourceCompact{Type: r.Type}
+
+	if r.Type == string(types.ResourceTypePeer) {
+		idx, ok := e.peerOrder[r.ID]
+		if !ok {
+			return nil
 		}
+		out.PeerIndexSet = true
+		out.PeerIndex = idx
+		return out
 	}
+
+	publicID, ok := e.networkIdToPublicId[r.ID]
+	if !ok {
+		return nil
+	}
+	out.Id = publicID
+
 	return out
 }
 
@@ -389,7 +436,7 @@ func (e *componentEncoder) networkPublicId(xid string) (string, bool) {
 	return id, true
 }
 
-func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact {
+func (e *componentEncoder) encodeDNSSettings(s *nmdata.DNSSettings) *proto.DNSSettingsCompact {
 	if s == nil || len(s.DisabledManagementGroups) == 0 {
 		return nil
 	}
@@ -404,7 +451,7 @@ func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSet
 	return out
 }
 
-func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw {
+func (e *componentEncoder) encodeRoutes(routes []*nmdata.Route) []*proto.RouteRaw {
 	if len(routes) == 0 {
 		return nil
 	}
@@ -442,7 +489,7 @@ func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteR
 	return out
 }
 
-func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw {
+func (e *componentEncoder) encodeNameServerGroups(nsgs []*nmdata.NameServerGroup) []*proto.NameServerGroupRaw {
 	if len(nsgs) == 0 {
 		return nil
 	}
@@ -465,7 +512,7 @@ func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup)
 	return out
 }
 
-func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
+func encodeNameServers(servers []nmdata.NameServer) []*proto.NameServer {
 	if len(servers) == 0 {
 		return nil
 	}
@@ -480,7 +527,7 @@ func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
 	return out
 }
 
-func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
+func encodeSimpleRecords(records []nmdata.SimpleRecord) []*proto.SimpleRecord {
 	if len(records) == 0 {
 		return nil
 	}
@@ -497,7 +544,7 @@ func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
 	return out
 }
 
-func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
+func encodeCustomZones(zones []nmdata.CustomZone) []*proto.CustomZone {
 	if len(zones) == 0 {
 		return nil
 	}
@@ -513,7 +560,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
 	return out
 }
 
-func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw {
+func (e *componentEncoder) encodeNetworkResources(resources []*nmdata.NetworkResource) []*proto.NetworkResourceRaw {
 	if len(resources) == 0 {
 		return nil
 	}
@@ -542,7 +589,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentRe
 	return out
 }
 
-func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList {
+func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*nmdata.NetworkRouter) map[string]*proto.NetworkRouterList {
 	if len(routersMap) == 0 {
 		return nil
 	}
@@ -578,7 +625,7 @@ func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*ty
 	return out
 }
 
-func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds {
+func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*nmdata.Policy) map[string]*proto.PolicyIds {
 	if len(rpm) == 0 {
 		return nil
 	}
@@ -599,6 +646,9 @@ func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Pol
 		}
 		ids := make([]string, 0, len(policies))
 		for _, pol := range policies {
+			if pol == nil {
+				continue
+			}
 			ids = append(ids, pol.PublicID)
 		}
 		if len(ids) == 0 {
@@ -615,7 +665,7 @@ func (e *componentEncoder) encodeGroupIDToUserIDs(m map[string][]string) map[str
 	}
 	out := make(map[string]*proto.UserIDList, len(m))
 	for groupID, userIDs := range m {
-		id, ok := e.groupPublicXid(groupID)
+		id, ok := e.authorizedGroupKey(groupID)
 		if !ok || len(userIDs) == 0 {
 			continue
 		}
@@ -665,7 +715,7 @@ func (e *componentEncoder) encodePostureFailedPeers(m map[string]map[string]stru
 // (which shouldn't happen in production but the encoder is exported)
 // degrades to login_expiration_enabled = false, which makes
 // LoginExpired() return false for every peer.
-func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettingsCompact {
+func toAccountSettingsCompact(s *nmdata.AccountSettingsInfo) *proto.AccountSettingsCompact {
 	if s == nil {
 		return &proto.AccountSettingsCompact{}
 	}
@@ -675,7 +725,7 @@ func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettin
 	}
 }
 
-func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
+func toAccountNetwork(n *nmdata.Network) *proto.AccountNetwork {
 	if n == nil {
 		return nil
 	}
@@ -691,21 +741,21 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
 	return out
 }
 
-func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact {
+func toPeerCompact(p *nmdata.Peer) *proto.PeerCompact {
 	pc := &proto.PeerCompact{
 		WgPubKey:               decodeWgKey(p.Key),
 		SshPubKey:              []byte(p.SSHKey),
 		DnsLabel:               p.DNSLabel,
-		AgentVersion:           p.AgentVersion,
-		AddedWithSsoLogin:      p.AddedWithSSOLogin,
+		AgentVersion:           p.Meta.WtVersion,
+		AddedWithSsoLogin:      p.UserID != "",
 		LoginExpirationEnabled: p.LoginExpirationEnabled,
 		SshEnabled:             p.SSHEnabled,
-		SupportsIpv6:           p.SupportsIPv6,
-		SupportsSourcePrefixes: p.SupportsSourcePrefixes,
-		ServerSshAllowed:       p.ServerSSHAllowed,
-		ProxyEmbedded:          p.ProxyEmbedded,
+		SupportsIpv6:           p.SupportsIPv6(),
+		SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
+		ServerSshAllowed:       p.Meta.Flags.ServerSSHAllowed,
+		ProxyEmbedded:          p.ProxyMeta.Embedded,
 	}
-	if !p.LastLogin.IsZero() {
+	if p.LastLogin != nil {
 		pc.LastLoginUnixNano = p.LastLogin.UnixNano()
 	}
 	switch {
@@ -754,7 +804,7 @@ func portsToUint32(ports []string) []uint32 {
 	return out
 }
 
-func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range {
+func portRangesToProto(ranges []nmdata.RulePortRange) []*proto.PortInfo_Range {
 	if len(ranges) == 0 {
 		return nil
 	}
diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go
index f7a27ceba..6ee554e8b 100644
--- a/management/internals/shared/grpc/components_encoder_test.go
+++ b/management/internals/shared/grpc/components_encoder_test.go
@@ -16,7 +16,7 @@ import (
 
 	nbdns "github.com/netbirdio/netbird/dns"
 	"github.com/netbirdio/netbird/management/server/types"
-	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -152,66 +152,66 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool {
 }
 
 func newTestComponents() *types.NetworkMapComponents {
-	peerA := &types.ComponentPeer{
-		ID:           "peer-a",
-		Key:          testWgKeyA,
-		IP:           netip.AddrFrom4([4]byte{100, 64, 0, 1}),
-		DNSLabel:     "peera",
-		SSHKey:       "ssh-a",
-		AgentVersion: "0.40.0",
+	peerA := &nmdata.Peer{
+		ID:       "peer-a",
+		Key:      testWgKeyA,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, 1}),
+		DNSLabel: "peera",
+		SSHKey:   "ssh-a",
+		Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 	}
-	peerB := &types.ComponentPeer{
-		ID:           "peer-b",
-		Key:          testWgKeyB,
-		IP:           netip.AddrFrom4([4]byte{100, 64, 0, 2}),
-		IPv6:         netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}),
-		DNSLabel:     "peerb",
-		AgentVersion: "0.25.0",
+	peerB := &nmdata.Peer{
+		ID:       "peer-b",
+		Key:      testWgKeyB,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, 2}),
+		IPv6:     netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}),
+		DNSLabel: "peerb",
+		Meta:     nmdata.PeerSystemMeta{WtVersion: "0.25.0"},
 	}
-	peerC := &types.ComponentPeer{
-		ID:           "peer-c",
-		Key:          testWgKeyC,
-		IP:           netip.AddrFrom4([4]byte{100, 64, 0, 3}),
-		DNSLabel:     "peerc",
-		AgentVersion: "0.40.0",
+	peerC := &nmdata.Peer{
+		ID:       "peer-c",
+		Key:      testWgKeyC,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, 3}),
+		DNSLabel: "peerc",
+		Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 	}
 
 	return &types.NetworkMapComponents{
 		PeerID: "peer-a",
-		Network: &types.Network{
+		Network: &nmdata.Network{
 			Identifier: "net-test",
 			Net:        net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
 			Serial:     7,
 		},
-		AccountSettings: &types.AccountSettingsInfo{
+		AccountSettings: &nmdata.AccountSettingsInfo{
 			PeerLoginExpirationEnabled: true,
 			PeerLoginExpiration:        2 * time.Hour,
 		},
-		Peers: map[string]*types.ComponentPeer{
+		Peers: map[string]*nmdata.Peer{
 			"peer-a": peerA,
 			"peer-b": peerB,
 			"peer-c": peerC,
 		},
-		Groups: map[string]*types.ComponentGroup{
-			"group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}},
-			"group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}},
+		Groups: map[string]*nmdata.Group{
+			"group-src": {PublicID: "1", Name: "Src", Peers: []string{"peer-a"}},
+			"group-dst": {PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}},
 		},
-		Policies: []*types.Policy{
+		Policies: []*nmdata.Policy{
 			{
 				ID:       "pol-1",
 				PublicID: "10",
 				Enabled:  true,
-				Rules: []*types.PolicyRule{{
-					ID: "rule-1", Enabled: true, Action: types.PolicyTrafficActionAccept,
-					Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true,
+				Rules: []*nmdata.PolicyRule{{
+					ID: "rule-1", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
+					Protocol: string(types.PolicyRuleProtocolTCP), Bidirectional: true,
 					Ports:        []string{"22", "80"},
-					PortRanges:   []types.RulePortRange{{Start: 8000, End: 8100}},
+					PortRanges:   []nmdata.RulePortRange{{Start: 8000, End: 8100}},
 					Sources:      []string{"group-src"},
 					Destinations: []string{"group-dst"},
 				}},
 			},
 		},
-		RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC},
+		RouterPeers: map[string]*nmdata.Peer{"peer-c": peerC},
 	}
 }
 
@@ -304,6 +304,31 @@ func TestEncodeNetworkMapEnvelope_GroupsByAccountPublicId(t *testing.T) {
 	assert.Len(t, groupByID["2"].PeerIndexes, 2)
 }
 
+func TestEncodePolicy(t *testing.T) {
+	encoder := componentEncoder{peerOrder: map[string]uint32{"peerId": uint32(1234)}, networkIdToPublicId: map[string]string{"domain": "publicDomain", "host": "publicHost", "subnet": "publicSubnet"}}
+	assert.Equal(t,
+		encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "peerId"}),
+		&proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: uint32(1234)})
+	// verify invalid peer id results in nil
+	assert.Nil(t,
+		encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "boom"}))
+	assert.Equal(t,
+		encoder.resourceToProto(nmdata.Resource{Type: "domain", ID: "domain"}),
+		&proto.ResourceCompact{Type: "domain", Id: "publicDomain"})
+	assert.Equal(t,
+		encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "host"}),
+		&proto.ResourceCompact{Type: "host", Id: "publicHost"})
+	assert.Equal(t,
+		encoder.resourceToProto(nmdata.Resource{Type: "subnet", ID: "subnet"}),
+		&proto.ResourceCompact{Type: "subnet", Id: "publicSubnet"})
+	// verify invalid resource type results in nil
+	assert.Nil(t,
+		encoder.resourceToProto(nmdata.Resource{Type: "boom", ID: "boom"}))
+	// verify invalid networkresource id results in nil
+	assert.Nil(t,
+		encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "boom"}))
+}
+
 func TestEncodeNetworkMapEnvelope_PolicyExpansion(t *testing.T) {
 	c := newTestComponents()
 
@@ -377,12 +402,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) {
 
 func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
 	c := newTestComponents()
-	v6Only := &types.ComponentPeer{
-		ID:           "peer-v6",
-		Key:          testWgKeyA,
-		IPv6:         netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}),
-		DNSLabel:     "peerv6",
-		AgentVersion: "0.40.0",
+	v6Only := &nmdata.Peer{
+		ID:       "peer-v6",
+		Key:      testWgKeyA,
+		IPv6:     netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}),
+		DNSLabel: "peerv6",
+		Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 	}
 	c.Peers["peer-v6"] = v6Only
 
@@ -401,11 +426,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
 
 func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
 	c := newTestComponents()
-	c.Peers["peer-noip"] = &types.ComponentPeer{
-		ID:           "peer-noip",
-		Key:          testWgKeyA,
-		DNSLabel:     "peernoip",
-		AgentVersion: "0.40.0",
+	c.Peers["peer-noip"] = &nmdata.Peer{
+		ID:       "peer-noip",
+		Key:      testWgKeyA,
+		DNSLabel: "peernoip",
+		Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 	}
 
 	full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -423,7 +448,7 @@ func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
 
 func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) {
 	c := &types.NetworkMapComponents{
-		Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
+		Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
 	}
 
 	env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c})
@@ -440,9 +465,9 @@ func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) {
 func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) {
 	c := newTestComponents()
 	now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
-	c.Peers["peer-a"].AddedWithSSOLogin = true
+	c.Peers["peer-a"].UserID = "user-1"
 	c.Peers["peer-a"].LoginExpirationEnabled = true
-	c.Peers["peer-a"].LastLogin = now
+	c.Peers["peer-a"].LastLogin = &now
 
 	full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
 
@@ -472,7 +497,7 @@ func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) {
 
 func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
 	c := newTestComponents()
-	c.Routes = []*nbroute.Route{
+	c.Routes = []*nmdata.Route{
 		{
 			ID:                  "route-peer",
 			PublicID:            "100",
@@ -519,7 +544,7 @@ func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
 
 func TestEncodeNetworkMapEnvelope_RouteWithMissingPeerLeavesIndexUnset(t *testing.T) {
 	c := newTestComponents()
-	c.Routes = []*nbroute.Route{{
+	c.Routes = []*nmdata.Route{{
 		ID:       "route-x",
 		PublicID: "100",
 		Peer:     "peer-not-in-components",
@@ -539,21 +564,21 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing
 	// Policy that exists ONLY in ResourcePoliciesMap, not in c.Policies. This
 	// is the I1 case — without unionPolicies the encoder would silently
 	// drop it from the wire.
-	resourceOnlyPolicy := &types.Policy{
+	resourceOnlyPolicy := &nmdata.Policy{
 		ID: "pol-resource", PublicID: "99", Enabled: true,
-		Rules: []*types.PolicyRule{{
-			ID: "rule-r", Enabled: true, Action: types.PolicyTrafficActionAccept,
-			Protocol:     types.PolicyRuleProtocolTCP,
+		Rules: []*nmdata.PolicyRule{{
+			ID: "rule-r", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
+			Protocol:     string(types.PolicyRuleProtocolTCP),
 			Sources:      []string{"group-src"},
 			Destinations: []string{"group-dst"},
 		}},
 	}
-	c.ResourcePoliciesMap = map[string][]*types.Policy{
+	c.ResourcePoliciesMap = map[string][]*nmdata.Policy{
 		"resource-x": {c.Policies[0], resourceOnlyPolicy}, // shared + resource-only
 	}
 	// Resource must appear in components.NetworkResources with a seq id —
 	// encoder uses that to translate the xid map key to uint32.
-	c.NetworkResources = []*types.ComponentResource{
+	c.NetworkResources = []*nmdata.NetworkResource{
 		{ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true},
 	}
 
@@ -579,10 +604,10 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing
 
 func TestEncodeNetworkMapEnvelope_NameServerGroups(t *testing.T) {
 	c := newTestComponents()
-	c.NameServerGroups = []*nbdns.NameServerGroup{{
+	c.NameServerGroups = []*nmdata.NameServerGroup{{
 		ID: "nsg-1", PublicID: "50", Name: "Main", Description: "primary",
-		NameServers: []nbdns.NameServer{{
-			IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53,
+		NameServers: []nmdata.NameServer{{
+			IP: netip.MustParseAddr("8.8.8.8"), NSType: int(nbdns.UDPNameServerType), Port: 53,
 		}},
 		Groups:  []string{"group-src", "group-not-persisted"},
 		Primary: true, Enabled: true,
@@ -621,11 +646,11 @@ func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) {
 func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) {
 	c := newTestComponents()
 	c.NetworkXIDToPublicID = map[string]string{"net-1": "5"}
-	c.RoutersMap = map[string]map[string]*types.ComponentRouter{
+	c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
 		"net-1": {
 			"peer-c": {
-				PublicID: "200",
-				Peer:     "peer-c", Masquerade: true, Metric: 10, Enabled: true,
+				PublicID:   "200",
+				Masquerade: true, Metric: 10, Enabled: true,
 			},
 		},
 	}
@@ -651,14 +676,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) {
 	// peer_index reference must still resolve.
 	c := newTestComponents()
 	delete(c.Peers, "peer-c")
-	routerPeer := &types.ComponentPeer{
+	routerPeer := &nmdata.Peer{
 		ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
-		DNSLabel: "peerc", AgentVersion: "0.40.0",
+		DNSLabel: "peerc", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 	}
-	c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer}
+	c.RouterPeers = map[string]*nmdata.Peer{"peer-c": routerPeer}
 	c.NetworkXIDToPublicID = map[string]string{"net-1": "5"}
-	c.RoutersMap = map[string]map[string]*types.ComponentRouter{
-		"net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}},
+	c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
+		"net-1": {"peer-c": {PublicID: "1", Enabled: true}},
 	}
 
 	full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -672,15 +697,20 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) {
 func TestEncodeNetworkMapEnvelope_GroupIDToUserIDs(t *testing.T) {
 	c := newTestComponents()
 	c.GroupIDToUserIDs = map[string][]string{
-		"group-src":     {"user-1", "user-2"},
-		"group-missing": {"user-4"}, // group not in components → drop
+		"group-src":   {"user-1", "user-2"},
+		"group-users": {"user-4"},
 	}
 
 	full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
 
-	require.Len(t, full.GroupIdToUserIds, 1, "only present groups survive")
+	require.Len(t, full.GroupIdToUserIds, 2,
+		"a peer group is keyed by its public id, and a user group — which never appears in "+
+			"components.Groups — keeps its own id rather than being dropped, or the peer would "+
+			"receive no authorized SSH users at all")
 	require.Contains(t, full.GroupIdToUserIds, "1")
 	assert.ElementsMatch(t, []string{"user-1", "user-2"}, full.GroupIdToUserIds["1"].UserIds)
+	require.Contains(t, full.GroupIdToUserIds, "group-users")
+	assert.ElementsMatch(t, []string{"user-4"}, full.GroupIdToUserIds["group-users"].UserIds)
 }
 
 func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) {
@@ -691,9 +721,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) {
 
 func TestToProxyPatch_PopulatesAllFields(t *testing.T) {
 	nm := &types.NetworkMap{
-		Peers: []*types.ComponentPeer{{
+		Peers: []*nmdata.Peer{{
 			ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}),
-			DNSLabel: "extpeer", AgentVersion: "0.40.0",
+			DNSLabel: "extpeer", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 		}},
 		FirewallRules: []*types.FirewallRule{{
 			PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp",
@@ -765,7 +795,7 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
 
 func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
 	c := &types.NetworkMapComponents{
-		Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
+		Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
 		// AccountSettings deliberately nil
 	}
 
@@ -779,8 +809,8 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
 func emptyNetworkMapComponents() *types.NetworkMapComponents {
 	return types.EmptyNetworkMapComponents(
 		&types.NetworkMapComponents{
-			PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}},
-			Network: &types.Network{
+			PeerID: "peer-id", Peers: map[string]*nmdata.Peer{"peer-id": {}},
+			Network: &nmdata.Network{
 				Identifier: "net-empty",
 				Net:        net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
 				Serial:     9,
diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go
index 88fa4a22d..c059b2248 100644
--- a/management/internals/shared/grpc/components_envelope_response.go
+++ b/management/internals/shared/grpc/components_envelope_response.go
@@ -7,11 +7,11 @@ import (
 
 	"github.com/netbirdio/netbird/client/ssh/auth"
 	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
 	"github.com/netbirdio/netbird/shared/management/networkmap"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -31,14 +31,14 @@ func ToComponentSyncResponse(
 	config *nbconfig.Config,
 	httpConfig *nbconfig.HttpServerConfig,
 	deviceFlowConfig *nbconfig.DeviceAuthorizationFlow,
-	peer *nbpeer.Peer,
+	peer *nmdata.Peer,
 	turnCredentials *Token,
 	relayCredentials *Token,
 	components *types.NetworkMapComponents,
 	proxyPatch *types.NetworkMap,
 	dnsName string,
 	checks []*posture.Checks,
-	settings *types.Settings,
+	settings *nmdata.AccountSettingsInfo,
 	extraSettings *types.ExtraSettings,
 	peerGroups []string,
 	dnsFwdPort int64,
@@ -145,7 +145,7 @@ func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePr
 //
 // The full SSH AuthorizedUsers map is still produced by the client when it
 // runs Calculate() over the envelope.
-func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer) bool {
+func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nmdata.Peer) bool {
 	if c == nil || peer == nil {
 		return false
 	}
@@ -170,25 +170,25 @@ func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer)
 // ruleEnablesSSHForPeer returns true when rule is active, targets peer, and
 // either explicitly authorises SSH or covers the legacy TCP/22 path while the
 // peer itself has SSH enabled locally.
-func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool {
+func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peer *nmdata.Peer) bool {
 	if rule == nil || !rule.Enabled {
 		return false
 	}
 	if !peerInDestinations(c, rule, peer.ID) {
 		return false
 	}
-	if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
+	if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
 		return true
 	}
-	return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule)
+	return peer.SSHEnabled && nmdata.PolicyRuleImpliesLegacySSH(rule)
 }
 
 // peerInDestinations reports whether peerID is in any of rule.Destinations'
 // groups (or matches DestinationResource if it's a peer-typed resource —
 // for non-peer types Calculate falls through to group lookup, so we mirror
 // that exactly to avoid silent divergence).
-func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool {
-	if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
+func peerInDestinations(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peerID string) bool {
+	if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
 		return rule.DestinationResource.ID == peerID
 	}
 	for _, groupID := range rule.Destinations {
diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go
index 20f4e6824..039cb73f4 100644
--- a/management/internals/shared/grpc/components_envelope_response_test.go
+++ b/management/internals/shared/grpc/components_envelope_response_test.go
@@ -5,8 +5,8 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 // TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches:
@@ -17,16 +17,15 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 	const targetPeerID = "target"
 	const targetGroupID = "g_dst"
 
-	mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) {
-		peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
-		group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}}
+	mkComponents := func(rule *nmdata.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nmdata.Peer) {
+		peer := &nmdata.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
 		return &types.NetworkMapComponents{
-			Peers:  map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()},
-			Groups: map[string]*types.ComponentGroup{targetGroupID: group},
-			Policies: []*types.Policy{{
+			Peers:  map[string]*nmdata.Peer{targetPeerID: peer},
+			Groups: map[string]*nmdata.Group{targetGroupID: {Name: "dst", Peers: []string{targetPeerID}}},
+			Policies: []*nmdata.Policy{{
 				ID:      "p",
 				Enabled: true,
-				Rules:   []*types.PolicyRule{rule},
+				Rules:   []*nmdata.PolicyRule{rule},
 			}},
 		}, peer
 	}
@@ -34,14 +33,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 	cases := []struct {
 		name        string
 		peerSSH     bool
-		rule        types.PolicyRule
+		rule        nmdata.PolicyRule
 		wantEnabled bool
 	}{
 		{
 			name:    "explicit-netbird-ssh-activates-regardless-of-peer-ssh",
 			peerSSH: false,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -49,8 +48,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-tcp-22-with-peer-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -58,8 +57,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-tcp-22-without-peer-ssh-disabled",
 			peerSSH: false,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: false,
@@ -67,8 +66,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-tcp-22022-with-peer-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22022"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -76,8 +75,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-all-protocol-with-peer-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolALL,
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolALL),
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -85,10 +84,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-port-range-covers-22",
 			peerSSH: true,
-			rule: types.PolicyRule{
+			rule: nmdata.PolicyRule{
 				Enabled:      true,
-				Protocol:     types.PolicyRuleProtocolTCP,
-				PortRanges:   []types.RulePortRange{{Start: 20, End: 30}},
+				Protocol:     string(types.PolicyRuleProtocolTCP),
+				PortRanges:   []nmdata.RulePortRange{{Start: 20, End: 30}},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -96,8 +95,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "tcp-80-no-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"80"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: false,
@@ -105,8 +104,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "disabled-rule-skipped",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			rule: nmdata.PolicyRule{
+				Enabled: false, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: false,
@@ -114,8 +113,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "peer-not-in-destinations",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{"g_other"}, // target not in this group
 			},
 			wantEnabled: false,
@@ -123,21 +122,21 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "peer-typed-destination-resource-matches",
 			peerSSH: false,
-			rule: types.PolicyRule{
+			rule: nmdata.PolicyRule{
 				Enabled:             true,
-				Protocol:            types.PolicyRuleProtocolNetbirdSSH,
-				DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer},
+				Protocol:            string(types.PolicyRuleProtocolNetbirdSSH),
+				DestinationResource: nmdata.Resource{ID: targetPeerID, Type: string(types.ResourceTypePeer)},
 			},
 			wantEnabled: true,
 		},
 		{
 			name:    "non-peer-destination-resource-falls-through-to-groups",
 			peerSSH: false,
-			rule: types.PolicyRule{
+			rule: nmdata.PolicyRule{
 				Enabled:             true,
-				Protocol:            types.PolicyRuleProtocolNetbirdSSH,
-				DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type
-				Destinations:        []string{targetGroupID},                        // saved by group fallback
+				Protocol:            string(types.PolicyRuleProtocolNetbirdSSH),
+				DestinationResource: nmdata.Resource{ID: targetPeerID, Type: "host"}, // wrong type
+				Destinations:        []string{targetGroupID},                         // saved by group fallback
 			},
 			wantEnabled: true,
 		},
@@ -156,16 +155,16 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 // belt-and-suspenders presence guard mirroring Calculate's
 // getAllPeersFromGroups invariant.
 func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
-	peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true}
+	peer := &nmdata.Peer{ID: "missing", SSHEnabled: true}
 	c := &types.NetworkMapComponents{
-		Peers: map[string]*types.ComponentPeer{}, // target peer NOT present
-		Groups: map[string]*types.ComponentGroup{
-			"g": {ID: "g", Peers: []string{"missing"}},
+		Peers: map[string]*nmdata.Peer{}, // target peer NOT present
+		Groups: map[string]*nmdata.Group{
+			"g": {Peers: []string{"missing"}},
 		},
-		Policies: []*types.Policy{{
+		Policies: []*nmdata.Policy{{
 			ID: "p", Enabled: true,
-			Rules: []*types.PolicyRule{{
-				Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			Rules: []*nmdata.PolicyRule{{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{"g"},
 			}},
 		}},
@@ -179,6 +178,6 @@ func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
 // exported indirectly via ToComponentSyncResponse and may receive nil
 // components on graceful-degrade paths.
 func TestComputeSSHEnabledForPeer_NilInputs(t *testing.T) {
-	assert.False(t, computeSSHEnabledForPeer(nil, &nbpeer.Peer{ID: "x"}))
+	assert.False(t, computeSSHEnabledForPeer(nil, &nmdata.Peer{ID: "x"}))
 	assert.False(t, computeSSHEnabledForPeer(&types.NetworkMapComponents{}, nil))
 }
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index c30b27f9e..5640127ca 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -18,10 +18,10 @@ import (
 
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
 	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/netiputil"
 )
@@ -47,7 +47,7 @@ func init() {
 // nil when no server config is set (the fan-out network-map path) because clients treat any
 // non-nil config as authoritative: a config without a relay section is interpreted as relay
 // disabled and wipes the clients' relay URLs.
-func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *types.Settings) *proto.NetbirdConfig {
+func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *nmdata.AccountSettingsInfo) *proto.NetbirdConfig {
 	if config == nil {
 		return nil
 	}
@@ -119,7 +119,7 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken
 	return nbConfig
 }
 
-func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
+func toPeerConfig(peer *nmdata.Peer, network *nmdata.Network, dnsName string, settings *nmdata.AccountSettingsInfo, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
 	netmask, _ := network.Net.Mask.Size()
 	fqdn := peer.FQDN(dnsName)
 
@@ -154,7 +154,7 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set
 	return peerConfig
 }
 
-func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nbpeer.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *types.Settings, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
+func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nmdata.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *nmdata.AccountSettingsInfo, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
 	// IPv6 data in AllowedIPs and SourcePrefixes wildcard expansion depends on
 	// whether the target peer supports IPv6. Routes and firewall rules are already
 	// filtered at the source (network map builder).
diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go
index 38d370740..559699d8c 100644
--- a/management/internals/shared/grpc/conversion_test.go
+++ b/management/internals/shared/grpc/conversion_test.go
@@ -278,7 +278,7 @@ func TestToNetbirdConfig_RelayInvariant(t *testing.T) {
 	settings := &types.Settings{MetricsPushEnabled: true}
 
 	t.Run("nil server config returns nil config", func(t *testing.T) {
-		nbCfg := toNetbirdConfig(nil, nil, nil, nil, settings)
+		nbCfg := toNetbirdConfig(nil, nil, nil, nil, types.TwinAccountSettings(settings))
 		assert.Nil(t, nbCfg, "fan-out updates must not carry a partial NetbirdConfig even when settings are present")
 	})
 
@@ -293,7 +293,7 @@ func TestToNetbirdConfig_RelayInvariant(t *testing.T) {
 		}
 		relayToken := &Token{Payload: "token-payload", Signature: "token-signature"}
 
-		nbCfg := toNetbirdConfig(cfg, nil, relayToken, nil, settings)
+		nbCfg := toNetbirdConfig(cfg, nil, relayToken, nil, types.TwinAccountSettings(settings))
 		require.NotNil(t, nbCfg)
 		require.NotNil(t, nbCfg.Relay, "non-nil NetbirdConfig must include the relay section")
 		assert.Equal(t, cfg.Relay.Addresses, nbCfg.Relay.Urls, "relay URLs should match the server config")
@@ -329,7 +329,7 @@ func TestToPeerConfig_RoutingPeerDNSResolution(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			settings := &types.Settings{RoutingPeerDNSResolutionEnabled: tt.globalFlag}
-			cfg := toPeerConfig(newPeer(tt.embedded), network, "netbird.selfhosted", settings, nil, nil, false, tt.forceParam)
+			cfg := toPeerConfig(types.TwinPeer(newPeer(tt.embedded)), types.TwinNetwork(network), "netbird.selfhosted", types.TwinAccountSettings(settings), nil, nil, false, tt.forceParam)
 			assert.Equal(t, tt.wantEnabled, cfg.RoutingPeerDnsResolutionEnabled,
 				"RoutingPeerDnsResolutionEnabled should reflect global || embedded || forced")
 		})
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index 3d5f0a1b7..4435f6706 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -920,8 +920,8 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne
 
 	// if peer has reached this point then it has logged in
 	loginResp := &proto.LoginResponse{
-		NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, settings),
-		PeerConfig:    toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH, false),
+		NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, types.TwinAccountSettings(settings)),
+		PeerConfig:    toPeerConfig(types.TwinPeer(peer), types.TwinNetwork(network), s.networkMapController.GetDNSDomain(settings), types.TwinAccountSettings(settings), s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH, false),
 		Checks:        toProtocolChecks(ctx, postureChecks),
 	}
 
@@ -1052,9 +1052,9 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer
 			log.WithContext(ctx).Errorf("failed to build components for peer %s on initial sync: %v", peer.ID, err)
 			return status.Errorf(codes.Internal, "failed to build initial sync envelope")
 		}
-		plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, freshPeer, turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, settings, settings.Extra, peerGroups, freshDnsFwdPort)
+		plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort)
 	} else {
-		plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, peer, turnToken, relayToken, networkMap, dnsName, postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort)
+		plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnToken, relayToken, networkMap, dnsName, postureChecks, nil, types.TwinAccountSettings(settings), settings.Extra, peerGroups, dnsFwdPort)
 	}
 
 	key, err := s.secretsManager.GetWGKey()
diff --git a/management/internals/shared/requestbuffer/buffer.go b/management/internals/shared/requestbuffer/buffer.go
new file mode 100644
index 000000000..c3823776c
--- /dev/null
+++ b/management/internals/shared/requestbuffer/buffer.go
@@ -0,0 +1,102 @@
+// Package requestbuffer coalesces concurrent reads of the same expensive
+// resource into a single fetch.
+package requestbuffer
+
+import (
+	"context"
+	"os"
+	"sync"
+	"time"
+
+	log "github.com/sirupsen/logrus"
+)
+
+// FetchFunc reads the resource identified by key.
+type FetchFunc[T any] func(ctx context.Context, key string) (T, error)
+
+// Buffer batches requests per key: the first request opens a window, every
+// request arriving within it joins the batch, and a single fetch serves them
+// all. The fetch starts only after the window closed, so a caller never
+// observes data read before its own request.
+type Buffer[T any] struct {
+	ctx      context.Context
+	name     string
+	fetch    FetchFunc[T]
+	interval time.Duration
+
+	mu      sync.Mutex
+	waiting map[string][]chan result[T]
+}
+
+type result[T any] struct {
+	value T
+	err   error
+}
+
+// New returns a Buffer serving batched requests through fetch. ctx bounds the
+// fetches, not the callers, and must outlive them.
+func New[T any](ctx context.Context, name string, interval time.Duration, fetch FetchFunc[T]) *Buffer[T] {
+	return &Buffer[T]{
+		ctx:      ctx,
+		name:     name,
+		fetch:    fetch,
+		interval: interval,
+		waiting:  make(map[string][]chan result[T]),
+	}
+}
+
+// Get returns the value for key, sharing one fetch with the other callers of
+// the current batch. The value is shared as is, so callers must treat it as
+// read-only unless the fetch hands out copies.
+func (b *Buffer[T]) Get(ctx context.Context, key string) (T, error) {
+	ch := make(chan result[T], 1)
+
+	b.mu.Lock()
+	b.waiting[key] = append(b.waiting[key], ch)
+	first := len(b.waiting[key]) == 1
+	b.mu.Unlock()
+
+	if first {
+		time.AfterFunc(b.interval, func() { b.flush(key) })
+	}
+
+	select {
+	case res := <-ch:
+		return res.value, res.err
+	case <-ctx.Done():
+		var zero T
+		return zero, ctx.Err()
+	}
+}
+
+func (b *Buffer[T]) flush(key string) {
+	b.mu.Lock()
+	waiting := b.waiting[key]
+	delete(b.waiting, key)
+	b.mu.Unlock()
+
+	if len(waiting) == 0 {
+		return
+	}
+
+	start := time.Now()
+	value, err := b.fetch(b.ctx, key)
+	log.WithContext(b.ctx).Tracef("%s: fetched %s for %d waiters in %s", b.name, key, len(waiting), time.Since(start))
+
+	for _, ch := range waiting {
+		ch <- result[T]{value: value, err: err}
+	}
+}
+
+// Interval reads a buffer interval from envVar, falling back to def.
+func Interval(ctx context.Context, envVar string, def time.Duration) time.Duration {
+	value := os.Getenv(envVar)
+	interval, err := time.ParseDuration(value)
+	if err != nil {
+		if value != "" {
+			log.WithContext(ctx).Warnf("failed to parse %s: %s", envVar, err)
+		}
+		return def
+	}
+	return interval
+}
diff --git a/management/internals/shared/requestbuffer/buffer_test.go b/management/internals/shared/requestbuffer/buffer_test.go
new file mode 100644
index 000000000..9e356145e
--- /dev/null
+++ b/management/internals/shared/requestbuffer/buffer_test.go
@@ -0,0 +1,106 @@
+package requestbuffer
+
+import (
+	"context"
+	"errors"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestBufferCoalescesConcurrentRequests(t *testing.T) {
+	var fetches atomic.Int32
+	buffer := New(context.Background(), "test", 50*time.Millisecond,
+		func(ctx context.Context, key string) (string, error) {
+			fetches.Add(1)
+			return key, nil
+		})
+
+	var wg sync.WaitGroup
+	for range 10 {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			value, err := buffer.Get(context.Background(), "account")
+			assert.NoError(t, err)
+			assert.Equal(t, "account", value)
+		}()
+	}
+	wg.Wait()
+
+	assert.Equal(t, int32(1), fetches.Load())
+}
+
+func TestBufferSeparatesKeys(t *testing.T) {
+	keys := make(chan string, 2)
+	buffer := New(context.Background(), "test", 10*time.Millisecond,
+		func(ctx context.Context, key string) (string, error) {
+			keys <- key
+			return key, nil
+		})
+
+	var wg sync.WaitGroup
+	for _, key := range []string{"a", "b"} {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			_, err := buffer.Get(context.Background(), key)
+			assert.NoError(t, err)
+		}()
+	}
+	wg.Wait()
+	close(keys)
+
+	var fetched []string
+	for key := range keys {
+		fetched = append(fetched, key)
+	}
+	assert.ElementsMatch(t, []string{"a", "b"}, fetched)
+}
+
+func TestBufferFetchesAfterRequest(t *testing.T) {
+	var version atomic.Int32
+	buffer := New(context.Background(), "test", 10*time.Millisecond,
+		func(ctx context.Context, key string) (int32, error) {
+			return version.Load(), nil
+		})
+
+	first, err := buffer.Get(context.Background(), "account")
+	require.NoError(t, err)
+	assert.Equal(t, int32(0), first)
+
+	version.Store(1)
+
+	second, err := buffer.Get(context.Background(), "account")
+	require.NoError(t, err)
+	assert.Equal(t, int32(1), second)
+}
+
+func TestBufferPropagatesError(t *testing.T) {
+	fetchErr := errors.New("fetch failed")
+	buffer := New(context.Background(), "test", 10*time.Millisecond,
+		func(ctx context.Context, key string) (*int, error) {
+			return nil, fetchErr
+		})
+
+	value, err := buffer.Get(context.Background(), "account")
+	assert.ErrorIs(t, err, fetchErr)
+	assert.Nil(t, value)
+}
+
+func TestBufferHonorsCallerContext(t *testing.T) {
+	buffer := New(context.Background(), "test", time.Minute,
+		func(ctx context.Context, key string) (string, error) {
+			return key, nil
+		})
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
+	defer cancel()
+
+	_, err := buffer.Get(ctx, "account")
+	assert.ErrorIs(t, err, context.DeadlineExceeded)
+}
diff --git a/management/server/account_request_buffer.go b/management/server/account_request_buffer.go
index e1672c2d0..792099431 100644
--- a/management/server/account_request_buffer.go
+++ b/management/server/account_request_buffer.go
@@ -2,117 +2,38 @@ package server
 
 import (
 	"context"
-	"os"
-	"sync"
 	"time"
 
 	log "github.com/sirupsen/logrus"
 
+	"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
 	"github.com/netbirdio/netbird/management/server/store"
 	"github.com/netbirdio/netbird/management/server/types"
 )
 
-// AccountRequest holds the result channel to return the requested account.
-type AccountRequest struct {
-	AccountID  string
-	ResultChan chan *AccountResult
-}
-
-// AccountResult holds the account data or an error.
-type AccountResult struct {
-	Account *types.Account
-	Err     error
-}
+const defaultAccountBufferInterval = 100 * time.Millisecond
 
 type AccountRequestBuffer struct {
-	store               store.Store
-	getAccountRequests  map[string][]*AccountRequest
-	mu                  sync.Mutex
-	getAccountRequestCh chan *AccountRequest
-	bufferInterval      time.Duration
+	buffer *requestbuffer.Buffer[*types.Account]
 }
 
 func NewAccountRequestBuffer(ctx context.Context, store store.Store) *AccountRequestBuffer {
-	bufferIntervalStr := os.Getenv("NB_GET_ACCOUNT_BUFFER_INTERVAL")
-	bufferInterval, err := time.ParseDuration(bufferIntervalStr)
-	if err != nil {
-		if bufferIntervalStr != "" {
-			log.WithContext(ctx).Warnf("failed to parse account request buffer interval: %s", err)
-		}
-		bufferInterval = 100 * time.Millisecond
+	interval := requestbuffer.Interval(ctx, "NB_GET_ACCOUNT_BUFFER_INTERVAL", defaultAccountBufferInterval)
+	log.WithContext(ctx).Infof("set account request buffer interval to %s", interval)
+
+	return &AccountRequestBuffer{
+		buffer: requestbuffer.New(ctx, "account request buffer", interval, store.GetAccount),
 	}
-
-	log.WithContext(ctx).Infof("set account request buffer interval to %s", bufferInterval)
-
-	ac := AccountRequestBuffer{
-		store:               store,
-		getAccountRequests:  make(map[string][]*AccountRequest),
-		getAccountRequestCh: make(chan *AccountRequest),
-		bufferInterval:      bufferInterval,
-	}
-
-	go ac.processGetAccountRequests(ctx)
-
-	return &ac
 }
+
 func (ac *AccountRequestBuffer) GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error) {
-	req := &AccountRequest{
-		AccountID:  accountID,
-		ResultChan: make(chan *AccountResult, 1),
+	account, err := ac.buffer.Get(ctx, accountID)
+	if err != nil || account == nil {
+		return account, err
 	}
 
-	log.WithContext(ctx).Tracef("requesting account %s with backpressure", accountID)
-	startTime := time.Now()
-	ac.getAccountRequestCh <- req
-
-	result := <-req.ResultChan
-	log.WithContext(ctx).Tracef("got account with backpressure after %s", time.Since(startTime))
-	return result.Account, result.Err
-}
-
-func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, accountID string) {
-	ac.mu.Lock()
-	requests := ac.getAccountRequests[accountID]
-	delete(ac.getAccountRequests, accountID)
-	ac.mu.Unlock()
-
-	if len(requests) == 0 {
-		return
-	}
-
-	startTime := time.Now()
-	account, err := ac.store.GetAccount(ctx, accountID)
-	log.WithContext(ctx).Tracef("getting account %s in batch took %s", accountID, time.Since(startTime))
-	result := &AccountResult{Account: account, Err: err}
-
-	for _, req := range requests {
-		if account != nil {
-			// Shallow copy the account so each goroutine gets its own struct value.
-			// This prevents data races when callers mutate fields like Policies.
-			accountCopy := *account
-			req.ResultChan <- &AccountResult{Account: &accountCopy, Err: err}
-		} else {
-			req.ResultChan <- result
-		}
-		close(req.ResultChan)
-	}
-}
-
-func (ac *AccountRequestBuffer) processGetAccountRequests(ctx context.Context) {
-	for {
-		select {
-		case req := <-ac.getAccountRequestCh:
-			ac.mu.Lock()
-			ac.getAccountRequests[req.AccountID] = append(ac.getAccountRequests[req.AccountID], req)
-			if len(ac.getAccountRequests[req.AccountID]) == 1 {
-				go func(ctx context.Context, accountID string) {
-					time.Sleep(ac.bufferInterval)
-					ac.processGetAccountBatch(ctx, accountID)
-				}(ctx, req.AccountID)
-			}
-			ac.mu.Unlock()
-		case <-ctx.Done():
-			return
-		}
-	}
+	// Shallow copy the account so each caller gets its own struct value.
+	// This prevents data races when callers mutate fields like Policies.
+	accountCopy := *account
+	return &accountCopy, nil
 }
diff --git a/management/server/account_test.go b/management/server/account_test.go
index 5a826e103..a5a484c1a 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -3331,7 +3331,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 	manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	if err != nil {
 		return nil, nil, err
diff --git a/management/server/affected_peers_property_test.go b/management/server/affected_peers_property_test.go
index f393465bc..b64aeb813 100644
--- a/management/server/affected_peers_property_test.go
+++ b/management/server/affected_peers_property_test.go
@@ -27,8 +27,6 @@ func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string)
 	account, err := manager.Store.GetAccount(ctx, accountID)
 	require.NoError(t, err)
 
-	account.InjectProxyPolicies(ctx)
-
 	validated := make(map[string]struct{}, len(account.Peers))
 	for id := range account.Peers {
 		validated[id] = struct{}{}
diff --git a/management/server/dns_test.go b/management/server/dns_test.go
index d7667a304..25bef664c 100644
--- a/management/server/dns_test.go
+++ b/management/server/dns_test.go
@@ -234,7 +234,7 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 
 	return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 }
diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go
index 6d19b1c35..893be1e5a 100644
--- a/management/server/groups/manager.go
+++ b/management/server/groups/manager.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/netbirdio/netbird/management/server/account"
 	"github.com/netbirdio/netbird/management/server/activity"
-	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	"github.com/netbirdio/netbird/management/server/permissions"
 	"github.com/netbirdio/netbird/management/server/permissions/modules"
 	"github.com/netbirdio/netbird/management/server/permissions/operations"
@@ -31,10 +30,6 @@ type managerImpl struct {
 	accountManager     account.Manager
 }
 
-func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any {
-	return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type}
-}
-
 type mockManager struct {
 }
 
@@ -114,7 +109,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans
 	}
 
 	event := func() {
-		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource))
+		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource)))
 	}
 
 	return event, nil
@@ -138,7 +133,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context,
 	}
 
 	event := func() {
-		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource))
+		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource)))
 	}
 
 	return event, nil
diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go
index 03a37c3ec..1d7dd69f5 100644
--- a/management/server/http/handlers/peers/peers_handler.go
+++ b/management/server/http/handlers/peers/peers_handler.go
@@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
 
 	netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
 
-	util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain))
+	util.WriteJSONObject(ctx, w, toAccessiblePeers(account.Peers, netMap, dnsDomain))
 }
 
 func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) {
@@ -534,20 +534,22 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
 	util.WriteJSONObject(r.Context(), w, resp)
 }
 
-// toAccessiblePeers rehydrates the calculated map's component peers into the
-// account's full peer objects, which carry the location/status/meta fields
-// the API response needs.
-func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer {
+// toAccessiblePeers resolves the twin peers in netMap back to the full account
+// peers (by ID) so the API response keeps Status/Name/OS/GeoNameID, which the
+// slim netmap twins intentionally don't carry.
+func toAccessiblePeers(accountPeers map[string]*nbpeer.Peer, netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer {
 	accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers))
-	add := func(peers []*types.ComponentPeer) {
-		for _, p := range peers {
-			if peer := accountPeers[p.ID]; peer != nil {
-				accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain))
-			}
+	appendByID := func(id string) {
+		if p, ok := accountPeers[id]; ok && p != nil {
+			accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain))
 		}
 	}
-	add(netMap.Peers)
-	add(netMap.OfflinePeers)
+	for _, p := range netMap.Peers {
+		appendByID(p.ID)
+	}
+	for _, p := range netMap.OfflinePeers {
+		appendByID(p.ID)
+	}
 
 	return accessiblePeers
 }
diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go
index 8b05b2ddf..44408d751 100644
--- a/management/server/http/testing/testing_tools/channel/channel.go
+++ b/management/server/http/testing/testing_tools/channel/channel.go
@@ -96,7 +96,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
 	}
 
 	requestBuffer := server.NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
 	am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore)
 	if err != nil {
 		t.Fatalf("Failed to create manager: %v", err)
@@ -226,7 +226,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
 	}
 
 	requestBuffer := server.NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
 	am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore)
 	if err != nil {
 		t.Fatalf("Failed to create manager: %v", err)
diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go
index b55d4f24c..eef69dc14 100644
--- a/management/server/identity_provider_test.go
+++ b/management/server/identity_provider_test.go
@@ -92,7 +92,7 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, testStore)
-	networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{})
+	networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil)
 	manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	if err != nil {
 		return nil, nil, err
diff --git a/management/server/integrated_validator.go b/management/server/integrated_validator.go
index 69ea668ad..9ec1f491e 100644
--- a/management/server/integrated_validator.go
+++ b/management/server/integrated_validator.go
@@ -11,6 +11,7 @@ import (
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/store"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 // UpdateIntegratedValidator updates the integrated validator groups for a specified account.
@@ -109,7 +110,7 @@ func (am *DefaultAccountManager) GetValidatedPeers(ctx context.Context, accountI
 		return nil, nil, err
 	}
 
-	validPeers, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, groups, peers, settings.Extra)
+	validPeers, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, types.TwinGroups(groups), types.TwinPeers(peers), settings.Extra)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -138,7 +139,7 @@ func (a MockIntegratedValidator) ValidatePeer(_ context.Context, update *nbpeer.
 	return update, false, nil
 }
 
-func (a MockIntegratedValidator) GetValidatedPeers(_ context.Context, accountID string, groups []*types.Group, peers []*nbpeer.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) {
+func (a MockIntegratedValidator) GetValidatedPeers(_ context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) {
 	validatedPeers := make(map[string]struct{})
 	for _, peer := range peers {
 		validatedPeers[peer.ID] = struct{}{}
diff --git a/management/server/integrations/integrated_validator/integrated_validator_mock.go b/management/server/integrations/integrated_validator/integrated_validator_mock.go
new file mode 100644
index 000000000..73178a869
--- /dev/null
+++ b/management/server/integrations/integrated_validator/integrated_validator_mock.go
@@ -0,0 +1,187 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ./interface.go
+//
+// Generated by this command:
+//
+//	mockgen -package integrated_validator -destination=integrated_validator_mock.go -source=./interface.go -build_flags=-mod=mod
+//
+
+// Package integrated_validator is a generated GoMock package.
+package integrated_validator
+
+import (
+	context "context"
+	reflect "reflect"
+
+	peer "github.com/netbirdio/netbird/management/server/peer"
+	types "github.com/netbirdio/netbird/management/server/types"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	proto "github.com/netbirdio/netbird/shared/management/proto"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockIntegratedValidator is a mock of IntegratedValidator interface.
+type MockIntegratedValidator struct {
+	ctrl     *gomock.Controller
+	recorder *MockIntegratedValidatorMockRecorder
+	isgomock struct{}
+}
+
+// MockIntegratedValidatorMockRecorder is the mock recorder for MockIntegratedValidator.
+type MockIntegratedValidatorMockRecorder struct {
+	mock *MockIntegratedValidator
+}
+
+// NewMockIntegratedValidator creates a new mock instance.
+func NewMockIntegratedValidator(ctrl *gomock.Controller) *MockIntegratedValidator {
+	mock := &MockIntegratedValidator{ctrl: ctrl}
+	mock.recorder = &MockIntegratedValidatorMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockIntegratedValidator) EXPECT() *MockIntegratedValidatorMockRecorder {
+	return m.recorder
+}
+
+// GetInvalidPeers mocks base method.
+func (m *MockIntegratedValidator) GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetInvalidPeers", ctx, accountID, extraSettings)
+	ret0, _ := ret[0].(map[string]string)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetInvalidPeers indicates an expected call of GetInvalidPeers.
+func (mr *MockIntegratedValidatorMockRecorder) GetInvalidPeers(ctx, accountID, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInvalidPeers", reflect.TypeOf((*MockIntegratedValidator)(nil).GetInvalidPeers), ctx, accountID, extraSettings)
+}
+
+// GetValidatedPeers mocks base method.
+func (m *MockIntegratedValidator) GetValidatedPeers(ctx context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetValidatedPeers", ctx, accountID, groups, peers, extraSettings)
+	ret0, _ := ret[0].(map[string]struct{})
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetValidatedPeers indicates an expected call of GetValidatedPeers.
+func (mr *MockIntegratedValidatorMockRecorder) GetValidatedPeers(ctx, accountID, groups, peers, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeers", reflect.TypeOf((*MockIntegratedValidator)(nil).GetValidatedPeers), ctx, accountID, groups, peers, extraSettings)
+}
+
+// IsNotValidPeer mocks base method.
+func (m *MockIntegratedValidator) IsNotValidPeer(ctx context.Context, accountID string, arg2 *peer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "IsNotValidPeer", ctx, accountID, arg2, peersGroup, extraSettings)
+	ret0, _ := ret[0].(bool)
+	ret1, _ := ret[1].(bool)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// IsNotValidPeer indicates an expected call of IsNotValidPeer.
+func (mr *MockIntegratedValidatorMockRecorder) IsNotValidPeer(ctx, accountID, arg2, peersGroup, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNotValidPeer", reflect.TypeOf((*MockIntegratedValidator)(nil).IsNotValidPeer), ctx, accountID, arg2, peersGroup, extraSettings)
+}
+
+// PeerDeleted mocks base method.
+func (m *MockIntegratedValidator) PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "PeerDeleted", ctx, accountID, peerID, extraSettings)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// PeerDeleted indicates an expected call of PeerDeleted.
+func (mr *MockIntegratedValidatorMockRecorder) PeerDeleted(ctx, accountID, peerID, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PeerDeleted", reflect.TypeOf((*MockIntegratedValidator)(nil).PeerDeleted), ctx, accountID, peerID, extraSettings)
+}
+
+// PreparePeer mocks base method.
+func (m *MockIntegratedValidator) PreparePeer(ctx context.Context, accountID string, p *peer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *peer.Peer {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "PreparePeer", ctx, accountID, p, peersGroup, extraSettings, temporary)
+	ret0, _ := ret[0].(*peer.Peer)
+	return ret0
+}
+
+// PreparePeer indicates an expected call of PreparePeer.
+func (mr *MockIntegratedValidatorMockRecorder) PreparePeer(ctx, accountID, p, peersGroup, extraSettings, temporary any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreparePeer", reflect.TypeOf((*MockIntegratedValidator)(nil).PreparePeer), ctx, accountID, p, peersGroup, extraSettings, temporary)
+}
+
+// SetPeerInvalidationListener mocks base method.
+func (m *MockIntegratedValidator) SetPeerInvalidationListener(fn func(string, []string)) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetPeerInvalidationListener", fn)
+}
+
+// SetPeerInvalidationListener indicates an expected call of SetPeerInvalidationListener.
+func (mr *MockIntegratedValidatorMockRecorder) SetPeerInvalidationListener(fn any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPeerInvalidationListener", reflect.TypeOf((*MockIntegratedValidator)(nil).SetPeerInvalidationListener), fn)
+}
+
+// Stop mocks base method.
+func (m *MockIntegratedValidator) Stop(ctx context.Context) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "Stop", ctx)
+}
+
+// Stop indicates an expected call of Stop.
+func (mr *MockIntegratedValidatorMockRecorder) Stop(ctx any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockIntegratedValidator)(nil).Stop), ctx)
+}
+
+// ValidateExtraSettings mocks base method.
+func (m *MockIntegratedValidator) ValidateExtraSettings(ctx context.Context, newExtraSettings, oldExtraSettings *types.ExtraSettings, userID, accountID string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ValidateExtraSettings", ctx, newExtraSettings, oldExtraSettings, userID, accountID)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// ValidateExtraSettings indicates an expected call of ValidateExtraSettings.
+func (mr *MockIntegratedValidatorMockRecorder) ValidateExtraSettings(ctx, newExtraSettings, oldExtraSettings, userID, accountID any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateExtraSettings", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidateExtraSettings), ctx, newExtraSettings, oldExtraSettings, userID, accountID)
+}
+
+// ValidateFlowResponse mocks base method.
+func (m *MockIntegratedValidator) ValidateFlowResponse(ctx context.Context, peerKey string, flowResponse *proto.PKCEAuthorizationFlow) *proto.PKCEAuthorizationFlow {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ValidateFlowResponse", ctx, peerKey, flowResponse)
+	ret0, _ := ret[0].(*proto.PKCEAuthorizationFlow)
+	return ret0
+}
+
+// ValidateFlowResponse indicates an expected call of ValidateFlowResponse.
+func (mr *MockIntegratedValidatorMockRecorder) ValidateFlowResponse(ctx, peerKey, flowResponse any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateFlowResponse", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidateFlowResponse), ctx, peerKey, flowResponse)
+}
+
+// ValidatePeer mocks base method.
+func (m *MockIntegratedValidator) ValidatePeer(ctx context.Context, update, p *peer.Peer, userID, accountID, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*peer.Peer, bool, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ValidatePeer", ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings)
+	ret0, _ := ret[0].(*peer.Peer)
+	ret1, _ := ret[1].(bool)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// ValidatePeer indicates an expected call of ValidatePeer.
+func (mr *MockIntegratedValidatorMockRecorder) ValidatePeer(ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePeer", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidatePeer), ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings)
+}
diff --git a/management/server/integrations/integrated_validator/interface.go b/management/server/integrations/integrated_validator/interface.go
index 326fbfaf0..dc3332177 100644
--- a/management/server/integrations/integrated_validator/interface.go
+++ b/management/server/integrations/integrated_validator/interface.go
@@ -5,16 +5,19 @@ import (
 
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
+//go:generate go tool mockgen -package integrated_validator -destination=integrated_validator_mock.go -source=./interface.go -build_flags=-mod=mod
+
 // IntegratedValidator interface exists to avoid the circle dependencies
 type IntegratedValidator interface {
 	ValidateExtraSettings(ctx context.Context, newExtraSettings *types.ExtraSettings, oldExtraSettings *types.ExtraSettings, userID string, accountID string) error
-	ValidatePeer(ctx context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error)
-	PreparePeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer
+	ValidatePeer(ctx context.Context, update *nbpeer.Peer, p *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error)
+	PreparePeer(ctx context.Context, accountID string, p *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer
 	IsNotValidPeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error)
-	GetValidatedPeers(ctx context.Context, accountID string, groups []*types.Group, peers []*nbpeer.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error)
+	GetValidatedPeers(ctx context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error)
 	GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error)
 	PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error
 	SetPeerInvalidationListener(fn func(accountID string, peerIDs []string))
diff --git a/management/server/integrations/integrated_validator/validator/validator.go b/management/server/integrations/integrated_validator/validator/validator.go
index db1d34373..33199c065 100644
--- a/management/server/integrations/integrated_validator/validator/validator.go
+++ b/management/server/integrations/integrated_validator/validator/validator.go
@@ -10,6 +10,7 @@ import (
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/settings"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -35,7 +36,7 @@ func (v *IntegratedValidatorImpl) IsNotValidPeer(_ context.Context, _ string, _
 	return false, false, nil
 }
 
-func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*types.Group, peers []*nbpeer.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) {
+func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*nmdata.Group, peers []*nmdata.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) {
 	validatedPeers := make(map[string]struct{})
 	for _, p := range peers {
 		validatedPeers[p.ID] = struct{}{}
diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go
index c23ca6237..4f8aa8265 100644
--- a/management/server/management_proto_test.go
+++ b/management/server/management_proto_test.go
@@ -376,7 +376,7 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
 		return nil, nil, "", cleanup, err
 	}
 
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config)
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config, nil)
 	accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "",
 		eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 
diff --git a/management/server/management_test.go b/management/server/management_test.go
index 80c76f0de..3a8d6ecc2 100644
--- a/management/server/management_test.go
+++ b/management/server/management_test.go
@@ -216,7 +216,7 @@ func startServer(
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := server.NewAccountRequestBuffer(ctx, str)
-	networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config)
+	networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil)
 
 	accountManager, err := server.BuildManager(
 		context.Background(),
diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go
index ce5d5d57b..deed9c34f 100644
--- a/management/server/nameserver_test.go
+++ b/management/server/nameserver_test.go
@@ -803,7 +803,7 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 
 	return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 }
diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go
index 643f9cdd6..4cf7f7ea3 100644
--- a/management/server/networks/resources/types/resource.go
+++ b/management/server/networks/resources/types/resource.go
@@ -14,7 +14,6 @@ import (
 	nbDomain "github.com/netbirdio/netbird/shared/management/domain"
 
 	"github.com/netbirdio/netbird/shared/management/http/api"
-	sharedTypes "github.com/netbirdio/netbird/shared/management/types"
 )
 
 type NetworkResourceType string
@@ -65,27 +64,6 @@ func NewNetworkResource(accountID, networkID, name, description, address string,
 	}, nil
 }
 
-// ToComponent converts the resource to its self-contained components
-// representation. Returns nil for a nil resource.
-func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource {
-	if n == nil {
-		return nil
-	}
-	return &sharedTypes.ComponentResource{
-		ID:          n.ID,
-		PublicID:    n.PublicID,
-		NetworkID:   n.NetworkID,
-		AccountID:   n.AccountID,
-		Name:        n.Name,
-		Description: n.Description,
-		Type:        sharedTypes.ComponentResourceType(n.Type),
-		Address:     n.Address,
-		Domain:      n.Domain,
-		Prefix:      n.Prefix,
-		Enabled:     n.Enabled,
-	}
-}
-
 func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource {
 	addr := n.Prefix.String()
 	if n.Type == Domain {
diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go
index b8097cdbb..189d7f792 100644
--- a/management/server/networks/routers/types/router.go
+++ b/management/server/networks/routers/types/router.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/netbirdio/netbird/management/server/networks/types"
 	"github.com/netbirdio/netbird/shared/management/http/api"
-	sharedTypes "github.com/netbirdio/netbird/shared/management/types"
 )
 
 type NetworkRouter struct {
@@ -22,36 +21,6 @@ type NetworkRouter struct {
 	Enabled    bool
 }
 
-// ToComponent converts the router to its self-contained components
-// representation. Returns nil for a nil router.
-func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter {
-	if n == nil {
-		return nil
-	}
-	return &sharedTypes.ComponentRouter{
-		NetworkID:  n.NetworkID,
-		PublicID:   n.PublicID,
-		Peer:       n.Peer,
-		PeerGroups: n.PeerGroups,
-		Masquerade: n.Masquerade,
-		Metric:     n.Metric,
-		Enabled:    n.Enabled,
-	}
-}
-
-// ToComponentMap converts a peer-keyed router map to its components
-// representation.
-func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter {
-	if routers == nil {
-		return nil
-	}
-	out := make(map[string]*sharedTypes.ComponentRouter, len(routers))
-	for id, r := range routers {
-		out[id] = r.ToComponent()
-	}
-	return out
-}
-
 func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) {
 	r := &NetworkRouter{
 		ID:         xid.New().String(),
diff --git a/management/server/peer.go b/management/server/peer.go
index 589cf9abf..579ff2708 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -21,6 +21,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/permissions/modules"
 	"github.com/netbirdio/netbird/management/server/permissions/operations"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/store"
@@ -1588,7 +1589,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []
 	}
 	seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers))
 	ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers))
-	add := func(peers []*types.ComponentPeer) {
+	add := func(peers []*nmdata.Peer) {
 		for _, p := range peers {
 			if p == nil || p.ID == "" || p.ID == selfPeerID {
 				continue
diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go
index a7be63ff9..80c77592c 100644
--- a/management/server/peer/peer.go
+++ b/management/server/peer/peer.go
@@ -13,14 +13,14 @@ import (
 
 	"github.com/netbirdio/netbird/management/server/util"
 	"github.com/netbirdio/netbird/shared/management/http/api"
-	sharedTypes "github.com/netbirdio/netbird/shared/management/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 // Peer capability constants mirror the proto enum values.
 const (
-	PeerCapabilitySourcePrefixes      int32 = 1
-	PeerCapabilityIPv6Overlay         int32 = 2
-	PeerCapabilityComponentNetworkMap int32 = 3
+	PeerCapabilitySourcePrefixes      = nmdata.PeerCapabilitySourcePrefixes
+	PeerCapabilityIPv6Overlay         = nmdata.PeerCapabilityIPv6Overlay
+	PeerCapabilityComponentNetworkMap = nmdata.PeerCapabilityComponentNetworkMap
 )
 
 // Peer represents a machine connected to the network.
@@ -206,36 +206,6 @@ func (p *Peer) AddedWithSSOLogin() bool {
 	return p.UserID != ""
 }
 
-// ToComponent converts the peer to its self-contained components
-// representation, carrying exactly the subset of peer data that crosses the
-// components wire format. Returns nil for a nil peer so callers can convert
-// possibly-missing peers without guarding.
-func (p *Peer) ToComponent() *sharedTypes.ComponentPeer {
-	if p == nil {
-		return nil
-	}
-	cp := &sharedTypes.ComponentPeer{
-		ID:                     p.ID,
-		Key:                    p.Key,
-		IP:                     p.IP,
-		IPv6:                   p.IPv6,
-		DNSLabel:               p.DNSLabel,
-		SSHKey:                 p.SSHKey,
-		SSHEnabled:             p.SSHEnabled,
-		ServerSSHAllowed:       p.Meta.Flags.ServerSSHAllowed,
-		AgentVersion:           p.Meta.WtVersion,
-		SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
-		SupportsIPv6:           p.SupportsIPv6(),
-		LoginExpirationEnabled: p.LoginExpirationEnabled,
-		AddedWithSSOLogin:      p.AddedWithSSOLogin(),
-		ProxyEmbedded:          p.ProxyMeta.Embedded,
-	}
-	if p.LastLogin != nil {
-		cp.LastLogin = *p.LastLogin
-	}
-	return cp
-}
-
 // HasCapability reports whether the peer has the given capability.
 func (p *Peer) HasCapability(capability int32) bool {
 	return slices.Contains(p.Meta.Capabilities, capability)
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index 80d270e98..9a662bdbf 100644
--- a/management/server/peer_test.go
+++ b/management/server/peer_test.go
@@ -57,6 +57,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/types"
 	nbroute "github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -1091,22 +1092,22 @@ func TestToSyncResponse(t *testing.T) {
 		Signature: "turn-pass",
 	}
 	networkMap := &types.NetworkMap{
-		Network: &types.Network{Net: *ipnet, Serial: 1000},
-		Peers: []*types.ComponentPeer{{
+		Network: &nmdata.Network{Net: *ipnet, Serial: 1000},
+		Peers: []*nmdata.Peer{{
 			IP:         netip.MustParseAddr("192.168.1.2"),
 			IPv6:       netip.MustParseAddr("fd00::2"),
 			Key:        "peer2-key",
 			DNSLabel:   "peer2",
 			SSHEnabled: true,
 			SSHKey:     "peer2-ssh-key"}},
-		OfflinePeers: []*types.ComponentPeer{{
+		OfflinePeers: []*nmdata.Peer{{
 			IP:         netip.MustParseAddr("192.168.1.3"),
 			IPv6:       netip.MustParseAddr("fd00::3"),
 			Key:        "peer3-key",
 			DNSLabel:   "peer3",
 			SSHEnabled: true,
 			SSHKey:     "peer3-ssh-key"}},
-		Routes: []*nbroute.Route{
+		Routes: []*nmdata.Route{
 			{
 				ID:          "route1",
 				Network:     netip.MustParsePrefix("10.0.0.0/24"),
@@ -1180,7 +1181,7 @@ func TestToSyncResponse(t *testing.T) {
 	}
 	dnsCache := &cache.DNSConfigCache{}
 	accountSettings := &types.Settings{RoutingPeerDNSResolutionEnabled: true}
-	response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, peer, turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, accountSettings, nil, []string{}, int64(dnsForwarderPort))
+	response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, types.TwinAccountSettings(accountSettings), nil, []string{}, int64(dnsForwarderPort))
 
 	assert.NotNil(t, response)
 	// assert peer config
@@ -1300,7 +1301,7 @@ func Test_RegisterPeerByUser(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
@@ -1391,7 +1392,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
@@ -1550,7 +1551,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
@@ -1635,7 +1636,7 @@ func Test_LoginPeer(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
diff --git a/management/server/route_test.go b/management/server/route_test.go
index 53dbb29d9..4ca9ee48f 100644
--- a/management/server/route_test.go
+++ b/management/server/route_test.go
@@ -1201,7 +1201,7 @@ func TestGetNetworkMap_RouteSync(t *testing.T) {
 	peer1Routes, err := am.GetNetworkMap(context.Background(), peer1ID)
 	require.NoError(t, err)
 	require.Len(t, peer1Routes.Routes, 1, "we should receive one route for peer1")
-	require.True(t, expectedRoute.Equal(peer1Routes.Routes[0]), "received route should be equal")
+	require.True(t, types.TwinRoute(expectedRoute).Equal(peer1Routes.Routes[0]), "received route should be equal")
 
 	peer2Routes, err := am.GetNetworkMap(context.Background(), peer2ID)
 	require.NoError(t, err)
@@ -1299,7 +1299,7 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel.
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	if err != nil {
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index 99bb2c2c1..6337ebf1a 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -3166,9 +3166,9 @@ func getGormConfig() *gorm.Config {
 
 // newPostgresStore initializes a new Postgres store.
 func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) {
-	dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy)
+	dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy)
 	if !ok {
-		return nil, fmt.Errorf("%s is not set", postgresDsnEnv)
+		return nil, fmt.Errorf("%s is not set", PostgresDsnEnv)
 	}
 	return NewPostgresqlStore(ctx, dsn, metrics, skipMigration)
 }
diff --git a/management/server/store/sql_store_get_account_test.go b/management/server/store/sql_store_get_account_test.go
index 56f2a6c41..686839b1f 100644
--- a/management/server/store/sql_store_get_account_test.go
+++ b/management/server/store/sql_store_get_account_test.go
@@ -13,7 +13,6 @@ import (
 	"github.com/stretchr/testify/require"
 
 	nbdns "github.com/netbirdio/netbird/dns"
-	"github.com/netbirdio/netbird/management/server/integration_reference"
 	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
 	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
@@ -21,6 +20,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/integration_reference"
 )
 
 // TestGetAccount_LoadsCustomDomains verifies GetAccount populates account.Domains.
diff --git a/management/server/store/store.go b/management/server/store/store.go
index ca911092b..7daeb28a9 100644
--- a/management/server/store/store.go
+++ b/management/server/store/store.go
@@ -436,8 +436,8 @@ type AgentNetworkMetrics struct {
 }
 
 const (
-	postgresDsnEnv       = "NB_STORE_ENGINE_POSTGRES_DSN"
-	postgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN"
+	PostgresDsnEnv       = "NB_STORE_ENGINE_POSTGRES_DSN"
+	PostgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN"
 	mysqlDsnEnv          = "NB_STORE_ENGINE_MYSQL_DSN"
 	mysqlDsnEnvLegacy    = "NETBIRD_STORE_ENGINE_MYSQL_DSN"
 )
@@ -781,7 +781,7 @@ func getSqlStoreEngine(ctx context.Context, store *SqlStore, kind types.Engine)
 }
 
 func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
-	dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy)
+	dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy)
 	if !ok || dsn == "" {
 		var err error
 		_, dsn, err = testutil.CreatePostgresTestContainer()
@@ -791,7 +791,7 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng
 	}
 
 	if dsn == "" {
-		return nil, nil, fmt.Errorf("%s is not set", postgresDsnEnv)
+		return nil, nil, fmt.Errorf("%s is not set", PostgresDsnEnv)
 	}
 
 	db, err := openDBWithRetry(dsn, kind, 5)
diff --git a/management/server/types/account.go b/management/server/types/account.go
index 4616fe26b..522bb8be6 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -9,7 +9,6 @@ import (
 	"strings"
 	"time"
 
-	"github.com/hashicorp/go-multierror"
 	"github.com/miekg/dns"
 	"github.com/rs/xid"
 	log "github.com/sirupsen/logrus"
@@ -18,8 +17,6 @@ import (
 	nbdns "github.com/netbirdio/netbird/dns"
 	proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
-	"github.com/netbirdio/netbird/management/internals/modules/zones"
-	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
 	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
 	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
@@ -28,11 +25,12 @@ import (
 	"github.com/netbirdio/netbird/management/server/util"
 	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/status"
 )
 
 const (
-	defaultTTL = 300
 	// privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly to clients.
 	privateServiceDNSRecordTTL      = 5
 	DefaultPeerLoginExpiration      = 24 * time.Hour
@@ -384,94 +382,11 @@ func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string)
 }
 
 func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdns.CustomZone {
-	var merr *multierror.Error
-
-	if dnsDomain == "" {
-		log.WithContext(ctx).Error("no dns domain is set, returning empty zone")
-		return nbdns.CustomZone{}
+	twins := make(map[string]*nmdata.Peer, len(a.Peers))
+	for id, p := range a.Peers {
+		twins[id] = twinPeer(p)
 	}
-
-	customZone := nbdns.CustomZone{
-		Domain:  dns.Fqdn(dnsDomain),
-		Records: make([]nbdns.SimpleRecord, 0, len(a.Peers)),
-	}
-
-	domainSuffix := "." + dnsDomain
-
-	ipv6AllowedPeers := a.peerIPv6AllowedSet()
-
-	var sb strings.Builder
-	for _, peer := range a.Peers {
-		if peer.DNSLabel == "" {
-			merr = multierror.Append(merr, fmt.Errorf("peer %s has an empty DNS label", peer.Name))
-			continue
-		}
-
-		sb.Grow(len(peer.DNSLabel) + len(domainSuffix))
-		sb.WriteString(peer.DNSLabel)
-		sb.WriteString(domainSuffix)
-
-		fqdn := sb.String()
-		customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
-			Name:  fqdn,
-			Type:  int(dns.TypeA),
-			Class: nbdns.DefaultClass,
-			TTL:   defaultTTL,
-			RData: peer.IP.String(),
-		})
-		// Only advertise AAAA for peers that have a valid IPv6, whose client supports it,
-		// and that belong to an IPv6-enabled group. Old clients don't configure v6 on their
-		// WireGuard interface, so resolving their AAAA causes connections to hang.
-		// Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate
-		// to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA
-		// records refresh when a peer first reports the IPv6 overlay capability.
-		_, peerAllowed := ipv6AllowedPeers[peer.ID]
-		hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed
-		if hasIPv6 {
-			customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
-				Name:  fqdn,
-				Type:  int(dns.TypeAAAA),
-				Class: nbdns.DefaultClass,
-				TTL:   defaultTTL,
-				RData: peer.IPv6.String(),
-			})
-		}
-		sb.Reset()
-
-		for _, extraLabel := range peer.ExtraDNSLabels {
-			sb.Grow(len(extraLabel) + len(domainSuffix))
-			sb.WriteString(extraLabel)
-			sb.WriteString(domainSuffix)
-
-			extraFqdn := sb.String()
-			customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
-				Name:  extraFqdn,
-				Type:  int(dns.TypeA),
-				Class: nbdns.DefaultClass,
-				TTL:   defaultTTL,
-				RData: peer.IP.String(),
-			})
-			if hasIPv6 {
-				customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
-					Name:  extraFqdn,
-					Type:  int(dns.TypeAAAA),
-					Class: nbdns.DefaultClass,
-					TTL:   defaultTTL,
-					RData: peer.IPv6.String(),
-				})
-			}
-			sb.Reset()
-		}
-
-	}
-
-	go func() {
-		if merr != nil {
-			log.WithContext(ctx).Errorf("error generating custom zone for account %s: %v", a.Id, merr)
-		}
-	}()
-
-	return customZone
+	return fromTwinCustomZone(networkmap.PeersCustomZone(ctx, a.Id, dnsDomain, twins, a.peerIPv6AllowedSet()))
 }
 
 // GetExpiredPeers returns peers that have been expired
@@ -1065,6 +980,26 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
 	return peers, fwRules, authorizedUsers, sshEnabled
 }
 
+// proxyTargetedDomainResourceIDs returns the set of domain network resource IDs
+// targeted by an enabled, non-terminated reverse-proxy service.
+func (a *Account) proxyTargetedDomainResourceIDs() map[string]struct{} {
+	ids := make(map[string]struct{})
+	for _, svc := range a.Services {
+		if svc == nil || !svc.Enabled || svc.Terminated {
+			continue
+		}
+		for _, target := range svc.Targets {
+			if target == nil || !target.Enabled {
+				continue
+			}
+			if target.TargetType == service.TargetTypeDomain {
+				ids[target.TargetId] = struct{}{}
+			}
+		}
+	}
+	return ids
+}
+
 func (a *Account) getAllowedUserIDs() map[string]struct{} {
 	users := make(map[string]struct{})
 	for _, nbUser := range a.Users {
@@ -1085,7 +1020,6 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
 	peersExists := make(map[string]struct{})
 	rules := make([]*FirewallRule, 0)
 	peers := make([]*nbpeer.Peer, 0)
-	targetComponent := targetPeer.ToComponent()
 
 	return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) {
 			for _, peer := range groupPeers {
@@ -1121,10 +1055,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
 				if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
 					rules = append(rules, &fr)
 				} else {
-					rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...)
+					rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...)
 				}
 
-				rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{
+				rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{
 					Direction:   direction,
 					DirStr:      strconv.Itoa(direction),
 					ProtocolStr: string(protocol),
@@ -1284,7 +1218,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli
 	return fwRules
 }
 
-func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer {
+func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer {
 	distPeersWithPolicy := make(map[string]struct{})
 	for _, id := range rule.Sources {
 		group := a.Groups[id]
@@ -1311,13 +1245,13 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID
 		}
 	}
 
-	distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy))
+	distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy))
 	for pID := range distPeersWithPolicy {
 		peer := a.Peers[pID]
 		if peer == nil {
 			continue
 		}
-		distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent())
+		distributionGroupPeers = append(distributionGroupPeers, peer)
 	}
 	return distributionGroupPeers
 }
@@ -1520,54 +1454,6 @@ func (a *Account) GetResourceRoutersMap() map[string]map[string]*routerTypes.Net
 	return routers
 }
 
-// forcesRoutingPeerDNSResolution reports whether the given peer must run
-// routing-peer DNS resolution regardless of the account-global
-// RoutingPeerDNSResolutionEnabled setting. It returns true when the peer is a
-// router for a domain network resource that is targeted by an enabled
-// reverse-proxy service, so the peer's DNS forwarder starts and can resolve
-// the target for the embedded proxy peers. Embedded proxy peers themselves are
-// handled at PeerConfig build time.
-func (a *Account) forcesRoutingPeerDNSResolution(peerID string, routers map[string]map[string]*routerTypes.NetworkRouter) bool {
-	targeted := a.proxyTargetedDomainResourceIDs()
-	if len(targeted) == 0 {
-		return false
-	}
-
-	for _, resource := range a.NetworkResources {
-		if resource == nil || !resource.Enabled || resource.Type != resourceTypes.Domain {
-			continue
-		}
-		if _, ok := targeted[resource.ID]; !ok {
-			continue
-		}
-		if _, isRouter := routers[resource.NetworkID][peerID]; isRouter {
-			return true
-		}
-	}
-
-	return false
-}
-
-// proxyTargetedDomainResourceIDs returns the set of domain network resource IDs
-// targeted by an enabled, non-terminated reverse-proxy service.
-func (a *Account) proxyTargetedDomainResourceIDs() map[string]struct{} {
-	ids := make(map[string]struct{})
-	for _, svc := range a.Services {
-		if svc == nil || !svc.Enabled || svc.Terminated {
-			continue
-		}
-		for _, target := range svc.Targets {
-			if target == nil || !target.Enabled {
-				continue
-			}
-			if target.TargetType == service.TargetTypeDomain {
-				ids[target.TargetId] = struct{}{}
-			}
-		}
-	}
-	return ids
-}
-
 // getPoliciesSourcePeers collects all unique peers from the source groups defined in the given policies.
 func getPoliciesSourcePeers(policies []*Policy, groups map[string]*Group) map[string]struct{} {
 	sourcePeers := make(map[string]struct{})
@@ -1668,176 +1554,6 @@ func (a *Account) GetProxyPeers() map[string][]*nbpeer.Peer {
 	return proxyPeers
 }
 
-func (a *Account) InjectProxyPolicies(ctx context.Context) {
-	if len(a.Services) == 0 {
-		return
-	}
-
-	proxyPeersByCluster := a.GetProxyPeers()
-	if len(proxyPeersByCluster) == 0 {
-		return
-	}
-
-	for _, service := range a.Services {
-		if !service.Enabled {
-			continue
-		}
-		a.injectServiceProxyPolicies(ctx, service, proxyPeersByCluster)
-	}
-
-}
-
-func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *service.Service, proxyPeersByCluster map[string][]*nbpeer.Peer) {
-	proxyPeers := proxyPeersByCluster[service.ProxyCluster]
-	for _, target := range service.Targets {
-		if !target.Enabled {
-			continue
-		}
-		a.injectTargetProxyPolicies(ctx, service, target, proxyPeers)
-	}
-
-	a.injectPrivateServicePolicies(service, proxyPeers)
-}
-
-// injectPrivateServicePolicies synthesises an in-memory ACL: AccessGroups → cluster proxy peers on TCP 80/443.
-func (a *Account) injectPrivateServicePolicies(svc *service.Service, proxyPeers []*nbpeer.Peer) {
-	if !svc.Private {
-		return
-	}
-	if len(svc.AccessGroups) == 0 {
-		return
-	}
-	if len(proxyPeers) == 0 {
-		return
-	}
-	// A service's AccessGroups can name groups that no longer exist — persisted
-	// services and the agent-network synthesiser both carry the ids verbatim from
-	// their own state. An unresolvable source authorises nothing, so drop it here
-	// rather than let the network-map assembly resolve it to a nil group.
-	sources := a.existingGroupIDs(svc.AccessGroups)
-	if len(sources) == 0 {
-		return
-	}
-	for _, proxyPeer := range proxyPeers {
-		a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer, sources))
-	}
-}
-
-// existingGroupIDs returns the subset of groupIDs that resolve to a group in the account,
-// preserving the input order.
-func (a *Account) existingGroupIDs(groupIDs []string) []string {
-	out := make([]string, 0, len(groupIDs))
-	for _, groupID := range groupIDs {
-		if _, ok := a.Groups[groupID]; ok {
-			out = append(out, groupID)
-		}
-	}
-	return out
-}
-
-func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer, accessGroups []string) *Policy {
-	policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
-	sources := append([]string(nil), accessGroups...)
-	return &Policy{
-		ID:      policyID,
-		Name:    fmt.Sprintf("Private Access to %s", svc.Name),
-		Enabled: true,
-		Rules: []*PolicyRule{
-			{
-				ID:       policyID,
-				PolicyID: policyID,
-				Name:     fmt.Sprintf("Allow access groups to reach %s", svc.Name),
-				Enabled:  true,
-				Sources:  sources,
-				DestinationResource: Resource{
-					ID:   proxyPeer.ID,
-					Type: ResourceTypePeer,
-				},
-				Bidirectional: false,
-				Protocol:      PolicyRuleProtocolTCP,
-				Action:        PolicyTrafficActionAccept,
-				PortRanges: []RulePortRange{
-					{Start: 80, End: 80},
-					{Start: 443, End: 443},
-				},
-			},
-		},
-	}
-}
-
-func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) {
-	port, ok := a.resolveTargetPort(ctx, target)
-	if !ok {
-		return
-	}
-
-	path := ""
-	if target.Path != nil {
-		path = *target.Path
-	}
-
-	for _, proxyPeer := range proxyPeers {
-		policy := a.createProxyPolicy(service, target, proxyPeer, port, path)
-		a.Policies = append(a.Policies, policy)
-	}
-}
-
-func (a *Account) resolveTargetPort(ctx context.Context, target *service.Target) (uint16, bool) {
-	if target.Port != 0 {
-		return target.Port, true
-	}
-
-	switch target.Protocol {
-	case "https", "tls":
-		return 443, true
-	case "http":
-		return 80, true
-	default:
-		log.WithContext(ctx).Warnf("unsupported protocol %s for proxy target %s, skipping policy injection", target.Protocol, target.TargetId)
-		return 0, false
-	}
-}
-
-func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy {
-	policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path)
-
-	protocol := PolicyRuleProtocolTCP
-	if svc.Mode == service.ModeUDP {
-		protocol = PolicyRuleProtocolUDP
-	}
-
-	return &Policy{
-		ID:      policyID,
-		Name:    fmt.Sprintf("Proxy Access to %s", svc.Name),
-		Enabled: true,
-		Rules: []*PolicyRule{
-			{
-				ID:       policyID,
-				PolicyID: policyID,
-				Name:     fmt.Sprintf("Allow access to %s", svc.Name),
-				Enabled:  true,
-				SourceResource: Resource{
-					ID:   proxyPeer.ID,
-					Type: ResourceTypePeer,
-				},
-				DestinationResource: Resource{
-					ID:   target.TargetId,
-					Type: ResourceType(target.TargetType),
-				},
-				Bidirectional: false,
-				Protocol:      protocol,
-				Action:        PolicyTrafficActionAccept,
-				PortRanges: []RulePortRange{
-					{
-						Start: port,
-						End:   port,
-					},
-				},
-			},
-		},
-	}
-}
-
 // filterZoneRecordsForPeers filters DNS records to only include peers to connect.
 // AAAA records are excluded when the requesting peer lacks IPv6 capability.
 func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord {
@@ -1870,66 +1586,3 @@ func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, p
 
 	return filteredRecords
 }
-
-// filterPeerAppliedZones filters account zones based on the peer's group membership
-func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone {
-	var customZones []nbdns.CustomZone
-
-	if len(peerGroups) == 0 {
-		return customZones
-	}
-
-	for _, zone := range accountZones {
-		if !zone.Enabled || len(zone.Records) == 0 {
-			continue
-		}
-
-		hasAccess := false
-		for _, distGroupID := range zone.DistributionGroups {
-			if _, found := peerGroups[distGroupID]; found {
-				hasAccess = true
-				break
-			}
-		}
-
-		if !hasAccess {
-			continue
-		}
-
-		simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records))
-		for _, record := range zone.Records {
-			var recordType int
-			rData := record.Content
-
-			switch record.Type {
-			case records.RecordTypeA:
-				recordType = int(dns.TypeA)
-			case records.RecordTypeAAAA:
-				recordType = int(dns.TypeAAAA)
-			case records.RecordTypeCNAME:
-				recordType = int(dns.TypeCNAME)
-				rData = dns.Fqdn(record.Content)
-			default:
-				log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID)
-				continue
-			}
-
-			simpleRecords = append(simpleRecords, nbdns.SimpleRecord{
-				Name:  dns.Fqdn(record.Name),
-				Type:  recordType,
-				Class: nbdns.DefaultClass,
-				TTL:   record.TTL,
-				RData: rData,
-			})
-		}
-
-		customZones = append(customZones, nbdns.CustomZone{
-			Domain:               dns.Fqdn(zone.Domain),
-			Records:              simpleRecords,
-			SearchDomainDisabled: !zone.EnableSearchDomain,
-			NonAuthoritative:     true,
-		})
-	}
-
-	return customZones
-}
diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go
index 3f2d5485f..3545fc8c8 100644
--- a/management/server/types/account_components.go
+++ b/management/server/types/account_components.go
@@ -2,7 +2,6 @@ package types
 
 import (
 	"context"
-	"slices"
 	"time"
 
 	log "github.com/sirupsen/logrus"
@@ -10,10 +9,7 @@ import (
 	nbdns "github.com/netbirdio/netbird/dns"
 	"github.com/netbirdio/netbird/management/internals/modules/zones"
 	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/telemetry"
-	"github.com/netbirdio/netbird/route"
 )
 
 // GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or
@@ -94,6 +90,9 @@ func (a *Account) GetPeerNetworkMapFromComponents(
 	return nm
 }
 
+// GetPeerNetworkMapComponents builds the account's slim twin store and computes
+// the peer's components on it. The calculation itself lives on
+// networkmap.NetworkMapData and never touches the Account.
 func (a *Account) GetPeerNetworkMapComponents(
 	ctx context.Context,
 	peerID string,
@@ -104,722 +103,19 @@ func (a *Account) GetPeerNetworkMapComponents(
 	routers map[string]map[string]*routerTypes.NetworkRouter,
 	groupIDToUserIDs map[string][]string,
 ) *NetworkMapComponents {
-	peer := a.Peers[peerID]
-	// this can never happen, things are very wrong if it did
-	// TODO (dmitri) maybe consider using invariants?
-	if peer == nil {
-		log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account")
-		return EmptyNetworkMapComponents(&NetworkMapComponents{
-			PeerID:  peerID,
-			Network: a.Network.Copy(),
-		})
-	}
 
-	if _, ok := validatedPeersMap[peerID]; !ok {
-		// Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents
-		// returns &NetworkMap{Network: a.Network.Copy()} when components is
-		// nil. Match that floor so the receiving client always sees the
-		// account Network identifier, not a fully-empty envelope.
-		return EmptyNetworkMapComponents(&NetworkMapComponents{
-			PeerID:  peerID,
-			Network: a.Network.Copy(),
-			// must include the target peer as it's required on the client
-			Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()},
-		})
-	}
-
-	components := &NetworkMapComponents{
-		PeerID:                    peerID,
-		Network:                   a.Network.Copy(),
-		NameServerGroups:          make([]*nbdns.NameServerGroup, 0),
-		CustomZoneDomain:          peersCustomZone.Domain,
-		ResourcePoliciesMap:       make(map[string][]*Policy),
-		RoutersMap:                make(map[string]map[string]*ComponentRouter),
-		NetworkResources:          make([]*ComponentResource, 0),
-		PostureFailedPeers:        make(map[string]map[string]struct{}, len(a.PostureChecks)),
-		RouterPeers:               make(map[string]*ComponentPeer),
-		NetworkXIDToPublicID:      make(map[string]string, len(a.Networks)),
-		PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
-
-		ForceRoutingPeerDNSResolution: a.forcesRoutingPeerDNSResolution(peerID, routers),
-	}
-	for _, n := range a.Networks {
-		if n != nil {
-			components.NetworkXIDToPublicID[n.ID] = n.PublicID
-		}
-	}
-	for _, pc := range a.PostureChecks {
-		if pc != nil {
-			components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
-		}
-	}
-
-	components.AccountSettings = &AccountSettingsInfo{
-		PeerLoginExpirationEnabled:      a.Settings.PeerLoginExpirationEnabled,
-		PeerLoginExpiration:             a.Settings.PeerLoginExpiration,
-		PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled,
-		PeerInactivityExpiration:        a.Settings.PeerInactivityExpiration,
-	}
-
-	components.DNSSettings = &a.DNSSettings
-
-	// relevantPeers always contains the target peer (peerID)
-	relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := a.getPeersGroupsPoliciesRoutes(ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers)
-
-	if len(sshReqs.neededGroupIDs) > 0 {
-		components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs)
-	}
-	if sshReqs.needAllowedUserIDs {
-		components.AllowedUserIDs = a.getAllowedUserIDs()
-	}
-
-	components.Peers = relevantPeers
-	components.Groups = GroupsToComponent(relevantGroups)
-	components.Policies = relevantPolicies
-	components.Routes = relevantRoutes
-	components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
-
-	peerGroups := a.GetPeerGroups(peerID)
-	components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups)
-	components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...)
-
-	for _, nsGroup := range a.NameServerGroups {
-		if nsGroup.Enabled {
-			for _, gID := range nsGroup.Groups {
-				if _, found := relevantGroups[gID]; found {
-					components.NameServerGroups = append(components.NameServerGroups, nsGroup)
-					break
-				}
-			}
-		}
-	}
-
-	for _, resource := range a.NetworkResources {
-		if !resource.Enabled {
-			continue
-		}
-
-		policies, exists := resourcePolicies[resource.ID]
-		if !exists {
-			continue
-		}
-
-		addSourcePeers := false
-
-		networkRoutingPeers, routerExists := routers[resource.NetworkID]
-		if routerExists {
-			if _, ok := networkRoutingPeers[peerID]; ok {
-				addSourcePeers = true
-			}
-		}
-
-		for _, policy := range policies {
-			if addSourcePeers {
-				var peers []string
-				if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
-					peers = []string{policy.Rules[0].SourceResource.ID}
-				} else {
-					peers = a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups())
-				}
-				for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) {
-					if _, exists := components.Peers[pID]; !exists {
-						components.Peers[pID] = a.GetPeer(pID).ToComponent()
-					}
-				}
-			} else {
-				peerInSources := false
-				if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
-					peerInSources = policy.Rules[0].SourceResource.ID == peerID
-				} else {
-					for _, groupID := range policy.SourceGroups() {
-						if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) {
-							peerInSources = true
-							break
-						}
-					}
-				}
-				if !peerInSources {
-					continue
-				}
-				isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, policy.SourcePostureChecks, peerID)
-				if !isValid && len(pname) > 0 {
-					if _, ok := components.PostureFailedPeers[pname]; !ok {
-						components.PostureFailedPeers[pname] = make(map[string]struct{})
-					}
-					components.PostureFailedPeers[pname][peer.ID] = struct{}{}
-					continue
-				}
-				addSourcePeers = true
-			}
-
-			for _, rule := range policy.Rules {
-				for _, srcGroupID := range rule.Sources {
-					if g := a.Groups[srcGroupID]; g != nil {
-						if _, exists := components.Groups[srcGroupID]; !exists {
-							components.Groups[srcGroupID] = g.ToComponent()
-						}
-					}
-				}
-				for _, dstGroupID := range rule.Destinations {
-					if g := a.Groups[dstGroupID]; g != nil {
-						if _, exists := components.Groups[dstGroupID]; !exists {
-							components.Groups[dstGroupID] = g.ToComponent()
-						}
-					}
-				}
-			}
-			components.ResourcePoliciesMap[resource.ID] = policies
-		}
-
-		// Only expose router peers and the per-network routers_map when this
-		// target peer actually has access to the resource (either as a router
-		// itself or via a policy that includes it as a source). Without this
-		// gate, every peer's envelope was leaking router peers of every
-		// network in the account — accounts with many tenants/networks
-		// shipped tens of unrelated peers in `peers[]` and `routers_map`.
-		if addSourcePeers {
-			components.RoutersMap[resource.NetworkID] = routerTypes.ToComponentMap(networkRoutingPeers)
-			for peerIDKey := range networkRoutingPeers {
-				if p := a.Peers[peerIDKey]; p != nil {
-					cp := components.RouterPeers[peerIDKey]
-					if cp == nil {
-						cp = p.ToComponent()
-						components.RouterPeers[peerIDKey] = cp
-					}
-					if _, exists := components.Peers[peerIDKey]; !exists {
-						if _, validated := validatedPeersMap[peerIDKey]; validated {
-							components.Peers[peerIDKey] = cp
-						}
-					}
-				}
-			}
-			components.NetworkResources = append(components.NetworkResources, resource.ToComponent())
-		}
-	}
-
-	filterGroupPeers(&components.Groups, components.Peers)
-	filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers)
-
-	return components
-}
-
-type sshRequirements struct {
-	neededGroupIDs     map[string]struct{}
-	needAllowedUserIDs bool
-}
-
-func (a *Account) getPeersGroupsPoliciesRoutes(
-	ctx context.Context,
-	peerID string,
-	peerSSHEnabled bool,
-	validatedPeersMap map[string]struct{},
-	postureFailedPeers *map[string]map[string]struct{},
-) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) {
-	relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4)
-	relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4)
-	relevantPolicies := make([]*Policy, 0, len(a.Policies))
-	relevantRoutes := make([]*route.Route, 0, len(a.Routes))
-	sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})}
-
-	relevantPeerIDs[peerID] = a.GetPeer(peerID).ToComponent()
-
-	peerGroupSet := make(map[string]struct{}, 8)
-	for groupID, group := range a.Groups {
-		if slices.Contains(group.Peers, peerID) {
-			relevantGroupIDs[groupID] = a.GetGroup(groupID)
-			peerGroupSet[groupID] = struct{}{}
-		}
-	}
-
-	routeAccessControlGroups := make(map[string]struct{})
-	for _, r := range a.Routes {
-		if r == nil {
-			continue
-		}
-		relevant := r.Peer == peerID
-		if !relevant {
-			for _, groupID := range r.PeerGroups {
-				if _, ok := peerGroupSet[groupID]; ok {
-					relevant = true
-					break
-				}
-			}
-		}
-		if !relevant && r.Enabled {
-			for _, groupID := range r.Groups {
-				if _, ok := peerGroupSet[groupID]; ok {
-					relevant = true
-					break
-				}
-			}
-		}
-		if !relevant {
-			continue
-		}
-
-		for _, groupID := range r.PeerGroups {
-			relevantGroupIDs[groupID] = a.GetGroup(groupID)
-		}
-		for _, groupID := range r.Groups {
-			relevantGroupIDs[groupID] = a.GetGroup(groupID)
-		}
-		if r.Enabled {
-			for _, groupID := range r.AccessControlGroups {
-				relevantGroupIDs[groupID] = a.GetGroup(groupID)
-				routeAccessControlGroups[groupID] = struct{}{}
-			}
-		}
-
-		// Include route advertisers in relevantPeerIDs. The envelope
-		// encoder writes route.peer_index by looking up r.Peer in the
-		// shipped peers list; if the advertiser is policy-isolated from
-		// the target peer (no rule edge between them), it would otherwise
-		// be omitted and the decoder would fail to resolve r.Peer, leaving
-		// the client without a WG tunnel target for this route. Legacy
-		// NetworkMap.Routes shipped the WG public key inline, so the
-		// equivalence path doesn't surface this — but the dependency is
-		// real once a client actually tries to use the route.
-		// Gate by validatedPeersMap so non-validated advertisers stay out
-		// (matches the network-resource router behaviour at the bottom of
-		// this loop, and the legacy invariant that only validated peers
-		// reach a client's view).
-		if r.Peer != "" {
-			if _, ok := validatedPeersMap[r.Peer]; ok {
-				if p := a.GetPeer(r.Peer); p != nil {
-					relevantPeerIDs[r.Peer] = p.ToComponent()
-				}
-			}
-		}
-		for _, groupID := range r.PeerGroups {
-			g := a.GetGroup(groupID)
-			if g == nil {
-				continue
-			}
-			for _, pid := range g.Peers {
-				if _, exists := relevantPeerIDs[pid]; exists {
-					continue
-				}
-				if _, ok := validatedPeersMap[pid]; !ok {
-					continue
-				}
-				if p := a.GetPeer(pid); p != nil {
-					relevantPeerIDs[pid] = p.ToComponent()
-				}
-			}
-		}
-		relevantRoutes = append(relevantRoutes, r)
-	}
-
-	for _, policy := range a.Policies {
-		if !policy.Enabled {
-			continue
-		}
-
-		policyRelevant := false
-		for _, rule := range policy.Rules {
-			if !rule.Enabled {
-				continue
-			}
-
-			if len(routeAccessControlGroups) > 0 {
-				for _, destGroupID := range rule.Destinations {
-					if _, needed := routeAccessControlGroups[destGroupID]; needed {
-						policyRelevant = true
-						for _, srcGroupID := range rule.Sources {
-							relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
-						}
-						for _, dstGroupID := range rule.Destinations {
-							relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
-						}
-						break
-					}
-				}
-			}
-
-			var sourcePeers, destinationPeers []string
-			var peerInSources, peerInDestinations bool
-
-			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
-				sourcePeers = []string{rule.SourceResource.ID}
-				if rule.SourceResource.ID == peerID {
-					peerInSources = true
-				}
-			} else {
-				sourcePeers, peerInSources = a.getPeersFromGroups(ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers)
-			}
-
-			if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
-				destinationPeers = []string{rule.DestinationResource.ID}
-				if rule.DestinationResource.ID == peerID {
-					peerInDestinations = true
-				}
-			} else {
-				destinationPeers, peerInDestinations = a.getPeersFromGroups(ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers)
-			}
-
-			if peerInSources {
-				policyRelevant = true
-				for _, pid := range destinationPeers {
-					if _, exists := relevantPeerIDs[pid]; !exists {
-						relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent()
-					}
-				}
-				for _, dstGroupID := range rule.Destinations {
-					relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
-				}
-			}
-
-			if peerInDestinations {
-				policyRelevant = true
-				for _, pid := range sourcePeers {
-					if _, exists := relevantPeerIDs[pid]; !exists {
-						relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent()
-					}
-				}
-				for _, srcGroupID := range rule.Sources {
-					relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
-				}
-
-				if rule.Protocol == PolicyRuleProtocolNetbirdSSH {
-					switch {
-					case len(rule.AuthorizedGroups) > 0:
-						for groupID := range rule.AuthorizedGroups {
-							sshReqs.neededGroupIDs[groupID] = struct{}{}
-						}
-					case rule.AuthorizedUser != "":
-					default:
-						sshReqs.needAllowedUserIDs = true
-					}
-				} else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled {
-					sshReqs.needAllowedUserIDs = true
-				}
-			}
-		}
-		if policyRelevant {
-			relevantPolicies = append(relevantPolicies, policy)
-		}
-	}
-
-	return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs
-}
-
-func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string,
-	validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
-	peerInGroups := false
-	var filteredPeerIDs []string
-	var seenPeerIds map[string]struct{}
-
-	for _, gid := range groups {
-		group := a.GetGroup(gid)
-		if group == nil {
-			continue
-		}
-
-		if group.IsGroupAll() || len(groups) == 1 {
-			filteredPeerIDs = make([]string, 0, len(group.Peers))
-			peerInGroups = false
-			for _, pid := range group.Peers {
-				peer, ok := a.Peers[pid]
-				if !ok || peer == nil {
-					continue
-				}
-
-				if _, ok := validatedPeersMap[peer.ID]; !ok {
-					continue
-				}
-
-				isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID)
-				if !isValid && len(pname) > 0 {
-					if _, ok := (*postureFailedPeers)[pname]; !ok {
-						(*postureFailedPeers)[pname] = make(map[string]struct{})
-					}
-					(*postureFailedPeers)[pname][peer.ID] = struct{}{}
-					continue
-				}
-
-				if peer.ID == peerID {
-					peerInGroups = true
-					continue
-				}
-
-				filteredPeerIDs = append(filteredPeerIDs, peer.ID)
-			}
-			return filteredPeerIDs, peerInGroups
-		}
-
-		if seenPeerIds == nil {
-			totalGroupPeers := 0
-			for _, g := range groups {
-				if grp := a.GetGroup(g); grp != nil {
-					totalGroupPeers += len(grp.Peers)
-				}
-			}
-			filteredPeerIDs = make([]string, 0, totalGroupPeers)
-			seenPeerIds = make(map[string]struct{}, totalGroupPeers)
-		}
-
-		for _, pid := range group.Peers {
-			if _, seen := seenPeerIds[pid]; seen {
-				continue
-			}
-			seenPeerIds[pid] = struct{}{}
-			peer, ok := a.Peers[pid]
-			if !ok || peer == nil {
-				continue
-			}
-
-			if _, ok := validatedPeersMap[peer.ID]; !ok {
-				continue
-			}
-
-			isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID)
-			if !isValid && len(pname) > 0 {
-				if _, ok := (*postureFailedPeers)[pname]; !ok {
-					(*postureFailedPeers)[pname] = make(map[string]struct{})
-				}
-				(*postureFailedPeers)[pname][peer.ID] = struct{}{}
-				continue
-			}
-
-			if peer.ID == peerID {
-				peerInGroups = true
-				continue
-			}
-
-			filteredPeerIDs = append(filteredPeerIDs, peer.ID)
-		}
-	}
-
-	return filteredPeerIDs, peerInGroups
-}
-
-func (a *Account) validatePostureChecksOnPeerGetFailed(ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) {
-	peer, ok := a.Peers[peerID]
-	if !ok || peer == nil {
-		return false, ""
-	}
-
-	for _, postureChecksID := range sourcePostureChecksID {
-		if valid, cached := a.cachedPostureCheckResult(postureChecksID, peerID); cached {
-			if !valid {
-				return false, postureChecksID
-			}
-			continue
-		}
-
-		postureChecks := a.GetPostureChecks(postureChecksID)
-		if postureChecks == nil {
-			continue
-		}
-
-		if !peerPassesPostureChecks(ctx, postureChecks.GetChecks(), peer) {
-			return false, postureChecksID
-		}
-	}
-	return true, ""
+	nmd := a.toNetworkMapData(accountZones, validatedPeersMap, resourcePolicies, routers, groupIDToUserIDs)
+	return nmd.GetPeerNetworkMapComponents(peerID, TwinCustomZone(peersCustomZone))
 }
 
 // PrecomputePostureValidation evaluates every posture check referenced by an enabled
-// policy once against the peers of that policy's source groups and stores the results,
-// so the per-peer network map calculations that follow look them up instead of
-// re-evaluating checks for every peer pair. It must be called before the account is
-// shared across goroutines; lookups not covered by the precomputed results fall back
-// to direct evaluation.
+// policy once and stores the results on the account, so the per-peer components
+// calculations that follow look them up instead of re-evaluating checks for every
+// peer pair. The evaluation itself runs on the twin store; every twin built from
+// this account afterwards inherits the results. It must be called before the
+// account is shared across goroutines.
 func (a *Account) PrecomputePostureValidation(ctx context.Context) {
-	if len(a.PostureChecks) == 0 {
-		a.PostureValidation = nil
-		return
-	}
-
-	checkPeerIDs := make(map[string]map[string]struct{})
-	for _, policy := range a.Policies {
-		if !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
-			continue
-		}
-
-		peerIDs := a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups())
-		for _, rule := range policy.Rules {
-			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
-				peerIDs = append(peerIDs, rule.SourceResource.ID)
-			}
-		}
-
-		for _, postureChecksID := range policy.SourcePostureChecks {
-			set := checkPeerIDs[postureChecksID]
-			if set == nil {
-				set = make(map[string]struct{}, len(peerIDs))
-				checkPeerIDs[postureChecksID] = set
-			}
-			for _, pid := range peerIDs {
-				set[pid] = struct{}{}
-			}
-		}
-	}
-
-	results := make(map[string]map[string]bool, len(checkPeerIDs))
-	for postureChecksID, peerIDs := range checkPeerIDs {
-		results[postureChecksID] = a.evaluatePostureChecksForPeers(ctx, postureChecksID, peerIDs)
-	}
-	a.PostureValidation = results
-}
-
-func (a *Account) evaluatePostureChecksForPeers(ctx context.Context, postureChecksID string, peerIDs map[string]struct{}) map[string]bool {
-	postureChecks := a.GetPostureChecks(postureChecksID)
-	if postureChecks == nil {
-		return nil
-	}
-
-	checks := postureChecks.GetChecks()
-	results := make(map[string]bool, len(peerIDs))
-	for peerID := range peerIDs {
-		peer, ok := a.Peers[peerID]
-		if !ok || peer == nil {
-			continue
-		}
-		results[peerID] = peerPassesPostureChecks(ctx, checks, peer)
-	}
-	return results
-}
-
-func (a *Account) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) {
-	results, ok := a.PostureValidation[postureChecksID]
-	if !ok {
-		return false, false
-	}
-	if results == nil {
-		return true, true
-	}
-	valid, found := results[peerID]
-	return valid, found
-}
-
-func peerPassesPostureChecks(ctx context.Context, checks []posture.Check, peer *nbpeer.Peer) bool {
-	for _, check := range checks {
-		isValid, _ := check.Check(ctx, *peer)
-		if !isValid {
-			return false
-		}
-	}
-	return true
-}
-
-func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string {
-	var dest []string
-	for _, peerID := range inputPeers {
-		if _, validated := validatedPeersMap[peerID]; !validated {
-			continue
-		}
-		valid, pname := a.validatePostureChecksOnPeerGetFailed(context.Background(), postureChecksIDs, peerID)
-		if valid {
-			dest = append(dest, peerID)
-			continue
-		}
-		if _, ok := (*postureFailedPeers)[pname]; !ok {
-			(*postureFailedPeers)[pname] = make(map[string]struct{})
-		}
-		(*postureFailedPeers)[pname][peerID] = struct{}{}
-	}
-	return dest
-}
-
-// filterGroupPeers trims each group's Peers slice to only those peers that
-// also appear in `peers`. Groups whose filtered list is empty are NOT
-// deleted from the map — they're kept so the components wire encoder can
-// still resolve seq references from routes/policies/access-control groups
-// that name them. Calculate() tolerates groups with empty Peers (the inner
-// loops simply iterate zero times), so retaining them is behaviourally a
-// no-op for the legacy path that consumes the same NetworkMapComponents.
-func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) {
-	for groupID, groupInfo := range *groups {
-		filteredPeers := make([]string, 0, len(groupInfo.Peers))
-		for _, pid := range groupInfo.Peers {
-			if _, exists := peers[pid]; exists {
-				filteredPeers = append(filteredPeers, pid)
-			}
-		}
-
-		if len(filteredPeers) != len(groupInfo.Peers) {
-			ng := *groupInfo
-			ng.Peers = filteredPeers
-			(*groups)[groupID] = &ng
-		}
-	}
-}
-
-func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) {
-	if len(*postureFailedPeers) == 0 {
-		return
-	}
-
-	referencedPostureChecks := make(map[string]struct{})
-	for _, policy := range policies {
-		for _, checkID := range policy.SourcePostureChecks {
-			referencedPostureChecks[checkID] = struct{}{}
-		}
-	}
-	for _, resPolicies := range resourcePoliciesMap {
-		for _, policy := range resPolicies {
-			for _, checkID := range policy.SourcePostureChecks {
-				referencedPostureChecks[checkID] = struct{}{}
-			}
-		}
-	}
-
-	for checkID, failedPeers := range *postureFailedPeers {
-		if _, referenced := referencedPostureChecks[checkID]; !referenced {
-			delete(*postureFailedPeers, checkID)
-			continue
-		}
-		for peerID := range failedPeers {
-			if _, exists := peers[peerID]; !exists {
-				delete(failedPeers, peerID)
-			}
-		}
-		if len(failedPeers) == 0 {
-			delete(*postureFailedPeers, checkID)
-		}
-	}
-}
-
-func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord {
-	if len(records) == 0 || len(peers) == 0 {
-		return nil
-	}
-
-	// Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6
-	// address) are not filtered out when peers have IPv6 assigned. When the
-	// requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped.
-	peerIPs := make(map[string]struct{}, len(peers)*2)
-	for _, peer := range peers {
-		if peer == nil {
-			continue
-		}
-		peerIPs[peer.IP.String()] = struct{}{}
-		if includeIPv6 && peer.IPv6.IsValid() {
-			peerIPs[peer.IPv6.String()] = struct{}{}
-		}
-	}
-
-	filteredRecords := make([]nbdns.SimpleRecord, 0, len(records))
-	for _, record := range records {
-		if _, exists := peerIPs[record.RData]; exists {
-			filteredRecords = append(filteredRecords, record)
-		}
-	}
-
-	return filteredRecords
-}
-
-func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string {
-	if len(neededGroupIDs) == 0 {
-		return nil
-	}
-
-	filtered := make(map[string][]string, len(neededGroupIDs))
-	for groupID := range neededGroupIDs {
-		if users, ok := fullMap[groupID]; ok {
-			filtered[groupID] = users
-		}
-	}
-	return filtered
+	nmd := a.toNetworkMapData(nil, nil, nil, nil, nil)
+	nmd.PrecomputePostureValidation()
+	a.PostureValidation = nmd.PostureValidation
 }
diff --git a/management/server/types/account_components_test.go b/management/server/types/account_components_test.go
index 3574480e8..99f5f9b72 100644
--- a/management/server/types/account_components_test.go
+++ b/management/server/types/account_components_test.go
@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/types"
 	"github.com/stretchr/testify/assert"
 )
@@ -14,7 +15,9 @@ func TestGetPeerNetworkMapComponents_PeerMissingFromAcount(t *testing.T) {
 	nmapcomponets := account.GetPeerNetworkMapComponents(context.TODO(), "missing-peer", dns.CustomZone{}, nil, nil, nil, nil, nil)
 
 	assert.Equal(t, EmptyNetworkMapComponents(&types.NetworkMapComponents{
-		PeerID:  "missing-peer",
-		Network: account.Network,
+		PeerID:                        "missing-peer",
+		Network:                       TwinNetwork(account.Network),
+		Peers:                         map[string]*nmdata.Peer{"missing-peer": nil},
+		ForceRoutingPeerDNSResolution: false,
 	}), nmapcomponets)
 }
diff --git a/management/server/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go
new file mode 100644
index 000000000..8f2e03a10
--- /dev/null
+++ b/management/server/types/account_networkmapdata.go
@@ -0,0 +1,613 @@
+package types
+
+import (
+	"github.com/miekg/dns"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
+	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/posture"
+	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// toNetworkMapData builds the slim twin store from the account once per
+// account. The per-peer components calculation then runs on the twin.
+func (a *Account) toNetworkMapData(
+	accountZones []*zones.Zone,
+	validatedPeersMap map[string]struct{},
+	resourcePolicies map[string][]*Policy,
+	routers map[string]map[string]*routerTypes.NetworkRouter,
+	groupIDToUserIDs map[string][]string,
+) *networkmap.NetworkMapData {
+	nmd := &networkmap.NetworkMapData{
+		Peers:                     make(map[string]*nmdata.Peer, len(a.Peers)),
+		Groups:                    make(map[string]*nmdata.Group, len(a.Groups)),
+		Policies:                  make([]*nmdata.Policy, 0, len(a.Policies)),
+		Routes:                    make([]*nmdata.Route, 0, len(a.Routes)),
+		NameServerGroups:          make([]*nmdata.NameServerGroup, 0, len(a.NameServerGroups)),
+		NetworkResources:          make([]*nmdata.NetworkResource, 0, len(a.NetworkResources)),
+		PostureChecks:             make(map[string]*nmdata.PostureChecks, len(a.PostureChecks)),
+		ResourcePolicies:          make(map[string][]*nmdata.Policy, len(resourcePolicies)),
+		Routers:                   make(map[string]map[string]*nmdata.NetworkRouter, len(routers)),
+		ValidatedPeers:            validatedPeersMap,
+		GroupIDToUserIDs:          groupIDToUserIDs,
+		PostureValidation:         a.PostureValidation,
+		AllowedUserIDs:            a.getAllowedUserIDs(),
+		NetworkXIDToPublicID:      make(map[string]string, len(a.Networks)),
+		PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
+	}
+
+	if a.Network != nil {
+		nmd.Network = TwinNetwork(a.Network)
+	}
+	nmd.DNSSettings = &nmdata.DNSSettings{DisabledManagementGroups: a.DNSSettings.DisabledManagementGroups}
+	nmd.AccountSettings = TwinAccountSettings(a.Settings)
+
+	for id, p := range a.Peers {
+		nmd.Peers[id] = twinPeer(p)
+	}
+	for id, g := range a.Groups {
+		nmd.Groups[id] = twinGroup(g)
+	}
+
+	policyCache := make(map[string]*nmdata.Policy, len(a.Policies))
+	twinPol := func(p *Policy) *nmdata.Policy {
+		if p == nil {
+			return nil
+		}
+		if tp, ok := policyCache[p.ID]; ok {
+			return tp
+		}
+		tp := twinPolicy(p)
+		policyCache[p.ID] = tp
+		return tp
+	}
+	for _, p := range a.Policies {
+		nmd.Policies = append(nmd.Policies, twinPol(p))
+	}
+	for resID, pols := range resourcePolicies {
+		twinPols := make([]*nmdata.Policy, 0, len(pols))
+		for _, p := range pols {
+			twinPols = append(twinPols, twinPol(p))
+		}
+		nmd.ResourcePolicies[resID] = twinPols
+	}
+
+	for _, r := range a.Routes {
+		if r == nil {
+			continue
+		}
+		nmd.Routes = append(nmd.Routes, twinRoute(r))
+	}
+	for _, nsg := range a.NameServerGroups {
+		nmd.NameServerGroups = append(nmd.NameServerGroups, twinNSG(nsg))
+	}
+	for _, res := range a.NetworkResources {
+		nmd.NetworkResources = append(nmd.NetworkResources, TwinNetworkResource(res))
+	}
+	for _, pc := range a.PostureChecks {
+		if pc != nil {
+			nmd.PostureChecks[pc.ID] = twinPostureChecks(pc)
+			nmd.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
+		}
+	}
+	for _, n := range a.Networks {
+		if n != nil {
+			nmd.NetworkXIDToPublicID[n.ID] = n.PublicID
+		}
+	}
+	for networkID, inner := range routers {
+		twinInner := make(map[string]*nmdata.NetworkRouter, len(inner))
+		for peerID, router := range inner {
+			twinInner[peerID] = twinRouter(router)
+		}
+		nmd.Routers[networkID] = twinInner
+	}
+
+	nmd.ProxyTargetedDomainResourceIDs = a.proxyTargetedDomainResourceIDs()
+	nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones)
+	nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates()
+	nmd.Services = TwinServices(a.Services)
+
+	return nmd
+}
+
+// TwinServices converts reverse-proxy services to their slim nmdata twins.
+// Exported for the network-map controller, which hands the store-backed twin
+// the same services the account carries.
+func TwinServices(services []*service.Service) []*nmdata.Service {
+	if len(services) == 0 {
+		return nil
+	}
+	out := make([]*nmdata.Service, 0, len(services))
+	for _, svc := range services {
+		if svc == nil {
+			continue
+		}
+		targets := make([]*nmdata.ServiceTarget, 0, len(svc.Targets))
+		for _, t := range svc.Targets {
+			if t == nil {
+				continue
+			}
+			path := ""
+			if t.Path != nil {
+				path = *t.Path
+			}
+			targets = append(targets, &nmdata.ServiceTarget{
+				Enabled:    t.Enabled,
+				Path:       path,
+				Port:       t.Port,
+				Protocol:   t.Protocol,
+				TargetID:   t.TargetId,
+				TargetType: string(t.TargetType),
+			})
+		}
+		out = append(out, &nmdata.Service{
+			ID:           svc.ID,
+			Enabled:      svc.Enabled,
+			Private:      svc.Private,
+			Mode:         svc.Mode,
+			ProxyCluster: svc.ProxyCluster,
+			AccessGroups: svc.AccessGroups,
+			Targets:      targets,
+		})
+	}
+	return out
+}
+
+func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
+	if p == nil {
+		return nil
+	}
+	networkAddresses := make([]nmdata.NetworkAddress, 0, len(p.Meta.NetworkAddresses))
+	for _, na := range p.Meta.NetworkAddresses {
+		networkAddresses = append(networkAddresses, nmdata.NetworkAddress{NetIP: na.NetIP})
+	}
+	files := make([]nmdata.File, 0, len(p.Meta.Files))
+	for _, f := range p.Meta.Files {
+		files = append(files, nmdata.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning})
+	}
+	return &nmdata.Peer{
+		ID:                     p.ID,
+		Key:                    p.Key,
+		SSHKey:                 p.SSHKey,
+		DNSLabel:               p.DNSLabel,
+		UserID:                 p.UserID,
+		SSHEnabled:             p.SSHEnabled,
+		LoginExpirationEnabled: p.LoginExpirationEnabled,
+		LastLogin:              p.LastLogin,
+		IP:                     p.IP,
+		IPv6:                   p.IPv6,
+		RequiresApproval:       p.Status != nil && p.Status.RequiresApproval,
+		ExtraDNSLabels:         p.ExtraDNSLabels,
+		ProxyMeta:              nmdata.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
+		Meta: nmdata.PeerSystemMeta{
+			WtVersion:          p.Meta.WtVersion,
+			GoOS:               p.Meta.GoOS,
+			OSVersion:          p.Meta.OSVersion,
+			KernelVersion:      p.Meta.KernelVersion,
+			NetworkAddresses:   networkAddresses,
+			Files:              files,
+			Capabilities:       p.Meta.Capabilities,
+			SyncMessageVersion: p.Meta.SyncMessageVersion,
+			Flags: nmdata.Flags{
+				ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
+				DisableIPv6:      p.Meta.Flags.DisableIPv6,
+			},
+		},
+		Location: nmdata.PeerLocation{
+			CountryCode:  p.Location.CountryCode,
+			CityName:     p.Location.CityName,
+			ConnectionIP: p.Location.ConnectionIP,
+		},
+	}
+}
+
+// TwinPeer converts a real peer to its slim nmdata twin. Exported for the
+// port-forwarding integration, which builds proxy NetworkMaps holding twins.
+func TwinPeer(p *nbpeer.Peer) *nmdata.Peer {
+	return twinPeer(p)
+}
+
+// TwinPeers converts real peers to their slim nmdata twins.
+func TwinPeers(peers []*nbpeer.Peer) []*nmdata.Peer {
+	out := make([]*nmdata.Peer, len(peers))
+	for i, p := range peers {
+		out[i] = twinPeer(p)
+	}
+	return out
+}
+
+// TwinGroups converts real groups to their slim nmdata twins.
+func TwinGroups(groups []*Group) []*nmdata.Group {
+	out := make([]*nmdata.Group, len(groups))
+	for i, g := range groups {
+		out[i] = twinGroup(g)
+	}
+	return out
+}
+
+func twinGroup(g *Group) *nmdata.Group {
+	if g == nil {
+		return nil
+	}
+	return &nmdata.Group{
+		ID:       g.ID,
+		Name:     g.Name,
+		PublicID: g.PublicID,
+		Peers:    g.Peers,
+	}
+}
+
+func twinPolicy(p *Policy) *nmdata.Policy {
+	if p == nil {
+		return nil
+	}
+	rules := make([]*nmdata.PolicyRule, 0, len(p.Rules))
+	for _, r := range p.Rules {
+		rules = append(rules, twinRule(r))
+	}
+	return &nmdata.Policy{
+		ID:                  p.ID,
+		PublicID:            p.PublicID,
+		Enabled:             p.Enabled,
+		SourcePostureChecks: p.SourcePostureChecks,
+		Rules:               rules,
+	}
+}
+
+func twinRule(r *PolicyRule) *nmdata.PolicyRule {
+	if r == nil {
+		return nil
+	}
+	var portRanges []nmdata.RulePortRange
+	if r.PortRanges != nil {
+		portRanges = make([]nmdata.RulePortRange, len(r.PortRanges))
+		for i, pr := range r.PortRanges {
+			portRanges[i] = nmdata.RulePortRange{Start: pr.Start, End: pr.End}
+		}
+	}
+	return &nmdata.PolicyRule{
+		ID:                  r.ID,
+		PolicyID:            r.PolicyID,
+		Enabled:             r.Enabled,
+		Action:              string(r.Action),
+		Protocol:            string(r.Protocol),
+		Bidirectional:       r.Bidirectional,
+		Sources:             r.Sources,
+		Destinations:        r.Destinations,
+		SourceResource:      nmdata.Resource{ID: r.SourceResource.ID, Type: string(r.SourceResource.Type)},
+		DestinationResource: nmdata.Resource{ID: r.DestinationResource.ID, Type: string(r.DestinationResource.Type)},
+		Ports:               r.Ports,
+		PortRanges:          portRanges,
+		AuthorizedGroups:    r.AuthorizedGroups,
+		AuthorizedUser:      r.AuthorizedUser,
+	}
+}
+
+func twinRoute(r *nbroute.Route) *nmdata.Route {
+	return &nmdata.Route{
+		ID:                  string(r.ID),
+		AccountID:           r.AccountID,
+		PublicID:            r.PublicID,
+		Network:             r.Network,
+		Domains:             r.Domains,
+		KeepRoute:           r.KeepRoute,
+		NetID:               string(r.NetID),
+		Description:         r.Description,
+		Peer:                r.Peer,
+		PeerID:              r.PeerID,
+		PeerGroups:          r.PeerGroups,
+		NetworkType:         int(r.NetworkType),
+		Masquerade:          r.Masquerade,
+		Metric:              r.Metric,
+		Enabled:             r.Enabled,
+		Groups:              r.Groups,
+		AccessControlGroups: r.AccessControlGroups,
+		SkipAutoApply:       r.SkipAutoApply,
+	}
+}
+
+// TwinRoute converts a real *route.Route to its slim nmdata twin. Exported for
+// tests that assert against twin routes returned in a NetworkMap.
+func TwinRoute(r *nbroute.Route) *nmdata.Route {
+	return twinRoute(r)
+}
+
+func TwinNetworkResource(r *resourceTypes.NetworkResource) *nmdata.NetworkResource {
+	if r == nil {
+		return nil
+	}
+	return &nmdata.NetworkResource{
+		ID:          r.ID,
+		NetworkID:   r.NetworkID,
+		AccountID:   r.AccountID,
+		PublicID:    r.PublicID,
+		Name:        r.Name,
+		Description: r.Description,
+		Type:        string(r.Type),
+		Address:     r.Address,
+		Domain:      r.Domain,
+		Prefix:      r.Prefix,
+		Enabled:     r.Enabled,
+	}
+}
+
+func twinRouter(r *routerTypes.NetworkRouter) *nmdata.NetworkRouter {
+	if r == nil {
+		return nil
+	}
+	return &nmdata.NetworkRouter{
+		PublicID:   r.PublicID,
+		PeerGroups: r.PeerGroups,
+		Masquerade: r.Masquerade,
+		Metric:     r.Metric,
+		Enabled:    r.Enabled,
+	}
+}
+
+func twinNSG(n *nbdns.NameServerGroup) *nmdata.NameServerGroup {
+	if n == nil {
+		return nil
+	}
+	nameServers := make([]nmdata.NameServer, 0, len(n.NameServers))
+	for _, ns := range n.NameServers {
+		nameServers = append(nameServers, nmdata.NameServer{
+			IP:     ns.IP,
+			NSType: int(ns.NSType),
+			Port:   ns.Port,
+		})
+	}
+	return &nmdata.NameServerGroup{
+		ID:                   n.ID,
+		PublicID:             n.PublicID,
+		Name:                 n.Name,
+		Description:          n.Description,
+		NameServers:          nameServers,
+		Groups:               n.Groups,
+		Primary:              n.Primary,
+		Domains:              n.Domains,
+		Enabled:              n.Enabled,
+		SearchDomainsEnabled: n.SearchDomainsEnabled,
+	}
+}
+
+// TwinNetwork converts a real *Network to its slim twin. Exported for the
+// graceful-degrade path that builds a minimal NetworkMapComponents directly.
+func TwinNetwork(n *Network) *nmdata.Network {
+	nc := n.Copy()
+	return &nmdata.Network{
+		Identifier: nc.Identifier,
+		Net:        nc.Net,
+		NetV6:      nc.NetV6,
+		Dns:        nc.Dns,
+		Serial:     int64(nc.Serial),
+	}
+}
+
+func twinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
+	if pc == nil {
+		return nil
+	}
+	out := &nmdata.PostureChecks{ID: pc.ID}
+	def := pc.Checks
+	if def.NBVersionCheck != nil {
+		out.Checks.NBVersionCheck = &nmdata.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion}
+	}
+	if def.OSVersionCheck != nil {
+		oc := &nmdata.OSVersionCheck{}
+		if def.OSVersionCheck.Android != nil {
+			oc.Android = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion}
+		}
+		if def.OSVersionCheck.Darwin != nil {
+			oc.Darwin = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion}
+		}
+		if def.OSVersionCheck.Ios != nil {
+			oc.Ios = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion}
+		}
+		if def.OSVersionCheck.Linux != nil {
+			oc.Linux = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion}
+		}
+		if def.OSVersionCheck.Windows != nil {
+			oc.Windows = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion}
+		}
+		out.Checks.OSVersionCheck = oc
+	}
+	if def.GeoLocationCheck != nil {
+		gc := &nmdata.GeoLocationCheck{Action: def.GeoLocationCheck.Action}
+		for _, loc := range def.GeoLocationCheck.Locations {
+			gc.Locations = append(gc.Locations, nmdata.GeoLocation{CountryCode: loc.CountryCode, CityName: loc.CityName})
+		}
+		out.Checks.GeoLocationCheck = gc
+	}
+	if def.PeerNetworkRangeCheck != nil {
+		out.Checks.PeerNetworkRangeCheck = &nmdata.PeerNetworkRangeCheck{
+			Action: def.PeerNetworkRangeCheck.Action,
+			Ranges: def.PeerNetworkRangeCheck.Ranges,
+		}
+	}
+	if def.ProcessCheck != nil {
+		procs := make([]nmdata.Process, 0, len(def.ProcessCheck.Processes))
+		for _, p := range def.ProcessCheck.Processes {
+			procs = append(procs, nmdata.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
+		}
+		out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs}
+	}
+	return out
+}
+
+// buildAppliedZoneCandidates precomputes the account-level custom DNS zones
+// (record conversion) once; the per-peer distribution-group gate runs in the
+// components calc. Mirrors the account-level half of filterPeerAppliedZones.
+func buildAppliedZoneCandidates(accountZones []*zones.Zone) []networkmap.AppliedZoneCandidate {
+	var out []networkmap.AppliedZoneCandidate
+	for _, zone := range accountZones {
+		if !zone.Enabled || len(zone.Records) == 0 {
+			continue
+		}
+		simpleRecords := make([]nmdata.SimpleRecord, 0, len(zone.Records))
+		for _, record := range zone.Records {
+			var recordType int
+			rData := record.Content
+			switch record.Type {
+			case records.RecordTypeA:
+				recordType = int(dns.TypeA)
+			case records.RecordTypeAAAA:
+				recordType = int(dns.TypeAAAA)
+			case records.RecordTypeCNAME:
+				recordType = int(dns.TypeCNAME)
+				rData = dns.Fqdn(record.Content)
+			default:
+				continue
+			}
+			simpleRecords = append(simpleRecords, nmdata.SimpleRecord{
+				Name:  dns.Fqdn(record.Name),
+				Type:  recordType,
+				Class: nbdns.DefaultClass,
+				TTL:   record.TTL,
+				RData: rData,
+			})
+		}
+		out = append(out, networkmap.AppliedZoneCandidate{
+			DistributionGroups: zone.DistributionGroups,
+			Zone: nmdata.CustomZone{
+				Domain:               dns.Fqdn(zone.Domain),
+				Records:              simpleRecords,
+				SearchDomainDisabled: !zone.EnableSearchDomain,
+				NonAuthoritative:     true,
+			},
+		})
+	}
+	return out
+}
+
+// buildPrivateServiceCandidates precomputes the connected-proxy A records per
+// private service (account-level); the per-peer access-group gate + apex merge
+// run in the components calc. Mirrors the account-level half of
+// SynthesizePrivateServiceZones.
+func (a *Account) buildPrivateServiceCandidates() []networkmap.PrivateServiceCandidate {
+	if len(a.Services) == 0 {
+		return nil
+	}
+	proxyPeersByCluster := a.GetProxyPeers()
+	if len(proxyPeersByCluster) == 0 {
+		return nil
+	}
+
+	var out []networkmap.PrivateServiceCandidate
+	for _, svc := range a.Services {
+		if svc == nil || !svc.Enabled || !svc.Private {
+			continue
+		}
+		if len(svc.AccessGroups) == 0 {
+			continue
+		}
+		proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+		if len(proxyPeers) == 0 {
+			continue
+		}
+		apex := a.privateServiceDomainZone(svc)
+		if apex == "" {
+			continue
+		}
+
+		var recs []nmdata.SimpleRecord
+		for _, p := range proxyPeers {
+			if p == nil || !p.IP.IsValid() {
+				continue
+			}
+			if p.Status == nil || !p.Status.Connected {
+				continue
+			}
+			recs = append(recs, nmdata.SimpleRecord{
+				Name:  dns.Fqdn(svc.Domain),
+				Type:  int(dns.TypeA),
+				Class: nbdns.DefaultClass,
+				TTL:   privateServiceDNSRecordTTL,
+				RData: p.IP.String(),
+			})
+		}
+		if len(recs) == 0 {
+			continue
+		}
+
+		out = append(out, networkmap.PrivateServiceCandidate{
+			AccessGroups: svc.AccessGroups,
+			Zone: nmdata.CustomZone{
+				Domain:               dns.Fqdn(apex),
+				Records:              recs,
+				NonAuthoritative:     true,
+				SearchDomainDisabled: true,
+			},
+		})
+	}
+	return out
+}
+
+// TwinAccountSettings converts real account settings to the slim nmdata twin.
+// Exported for callers of the twin-based sync response builders.
+func TwinAccountSettings(s *Settings) *nmdata.AccountSettingsInfo {
+	if s == nil {
+		return nil
+	}
+	return &nmdata.AccountSettingsInfo{
+		PeerLoginExpirationEnabled:      s.PeerLoginExpirationEnabled,
+		PeerLoginExpiration:             s.PeerLoginExpiration,
+		PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled,
+		PeerInactivityExpiration:        s.PeerInactivityExpiration,
+		DNSDomain:                       s.DNSDomain,
+		IPv6EnabledGroups:               s.IPv6EnabledGroups,
+		RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled,
+		LazyConnectionEnabled:           s.LazyConnectionEnabled,
+		AutoUpdateVersion:               s.AutoUpdateVersion,
+		AutoUpdateAlways:                s.AutoUpdateAlways,
+		MetricsPushEnabled:              s.MetricsPushEnabled,
+	}
+}
+
+func fromTwinCustomZone(z nmdata.CustomZone) nbdns.CustomZone {
+	records := make([]nbdns.SimpleRecord, 0, len(z.Records))
+	for _, r := range z.Records {
+		records = append(records, nbdns.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		})
+	}
+	return nbdns.CustomZone{
+		Domain:               z.Domain,
+		Records:              records,
+		SearchDomainDisabled: z.SearchDomainDisabled,
+		NonAuthoritative:     z.NonAuthoritative,
+	}
+}
+
+// TwinCustomZone converts a real DNS custom zone to its slim nmdata twin.
+// Exported for the network-map controller's DB-store path, which feeds real
+// zones into the twin-based components calculation.
+func TwinCustomZone(z nbdns.CustomZone) nmdata.CustomZone {
+	records := make([]nmdata.SimpleRecord, 0, len(z.Records))
+	for _, r := range z.Records {
+		records = append(records, nmdata.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		})
+	}
+	return nmdata.CustomZone{
+		Domain:               z.Domain,
+		Records:              records,
+		SearchDomainDisabled: z.SearchDomainDisabled,
+		NonAuthoritative:     z.NonAuthoritative,
+	}
+}
diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go
index 11b3d985a..5dccfbf30 100644
--- a/management/server/types/account_private_netmap_test.go
+++ b/management/server/types/account_private_netmap_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
@@ -17,7 +18,6 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
 	account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0"
 
 	ctx := context.Background()
-	account.InjectProxyPolicies(ctx)
 
 	validated := map[string]struct{}{
 		"user-peer":  {},
@@ -48,7 +48,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
 	})
 }
 
-func netmapPeerIDs(peers []*ComponentPeer) []string {
+func netmapPeerIDs(peers []*nmdata.Peer) []string {
 	ids := make([]string, 0, len(peers))
 	for _, p := range peers {
 		if p == nil {
diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go
index 80f2a950a..063b2d7e7 100644
--- a/management/server/types/account_test.go
+++ b/management/server/types/account_test.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"net"
 	"net/netip"
+	"strings"
 	"testing"
 
 	"github.com/miekg/dns"
@@ -13,13 +14,12 @@ import (
 
 	nbdns "github.com/netbirdio/netbird/dns"
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
-	"github.com/netbirdio/netbird/management/internals/modules/zones"
-	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
 	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
 	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func setupTestAccount() *Account {
@@ -666,7 +666,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent())
+			result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer)
 
 			var ports []string
 			for _, fr := range result {
@@ -1040,518 +1040,6 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
 	}
 }
 
-func Test_filterPeerAppliedZones(t *testing.T) {
-	ctx := context.Background()
-
-	tests := []struct {
-		name         string
-		accountZones []*zones.Zone
-		peerGroups   LookupMap
-		expected     []nbdns.CustomZone
-	}{
-		{
-			name:         "empty peer groups returns empty custom zones",
-			accountZones: []*zones.Zone{},
-			peerGroups:   LookupMap{},
-			expected:     []nbdns.CustomZone{},
-		},
-		{
-			name: "peer has access to zone with A record",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "example.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.example.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "example.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.example.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "peer has access to zone with search domain enabled",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "internal.local",
-					Enabled:            true,
-					EnableSearchDomain: true,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "api.internal.local",
-							Type:    records.RecordTypeA,
-							Content: "10.0.0.1",
-							TTL:     600,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "internal.local.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "api.internal.local.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   600,
-							RData: "10.0.0.1",
-						},
-					},
-					SearchDomainDisabled: false,
-				},
-			},
-		},
-		{
-			name: "peer has no access to zone",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "private.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group2"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "secret.private.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected:   []nbdns.CustomZone{},
-		},
-		{
-			name: "disabled zone is filtered out",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "disabled.com",
-					Enabled:            false,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.disabled.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected:   []nbdns.CustomZone{},
-		},
-		{
-			name: "zone with no records is filtered out",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "empty.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records:            []*records.Record{},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected:   []nbdns.CustomZone{},
-		},
-		{
-			name: "peer has access via multiple groups",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "multi.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1", "group2", "group3"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.multi.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group2": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "multi.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.multi.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "multiple zones with mixed access",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "allowed.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.allowed.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-				{
-					ID:                 "zone2",
-					Domain:             "denied.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group2"},
-					Records: []*records.Record{
-						{
-							ID:      "record2",
-							Name:    "www.denied.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.2",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "allowed.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.allowed.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "zone with multiple record types",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "mixed.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.mixed.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-						{
-							ID:      "record2",
-							Name:    "ipv6.mixed.com",
-							Type:    records.RecordTypeAAAA,
-							Content: "2001:db8::1",
-							TTL:     600,
-						},
-						{
-							ID:      "record3",
-							Name:    "alias.mixed.com",
-							Type:    records.RecordTypeCNAME,
-							Content: "www.mixed.com",
-							TTL:     900,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "mixed.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.mixed.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-						{
-							Name:  "ipv6.mixed.com.",
-							Type:  int(dns.TypeAAAA),
-							Class: nbdns.DefaultClass,
-							TTL:   600,
-							RData: "2001:db8::1",
-						},
-						{
-							Name:  "alias.mixed.com.",
-							Type:  int(dns.TypeCNAME),
-							Class: nbdns.DefaultClass,
-							TTL:   900,
-							RData: "www.mixed.com.",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "multiple zones both accessible",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "first.com",
-					Enabled:            true,
-					EnableSearchDomain: true,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.first.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-				{
-					ID:                 "zone2",
-					Domain:             "second.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record2",
-							Name:    "www.second.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.2",
-							TTL:     600,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "first.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.first.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: false,
-				},
-				{
-					Domain: "second.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.second.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   600,
-							RData: "192.168.1.2",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "zone with multiple records of same type",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "multi-a.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.multi-a.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-						{
-							ID:      "record2",
-							Name:    "www.multi-a.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.2",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "multi-a.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.multi-a.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-						{
-							Name:  "www.multi-a.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.2",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "peer in multiple groups accessing different zones",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "zone1.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.zone1.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-				{
-					ID:                 "zone2",
-					Domain:             "zone2.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group2"},
-					Records: []*records.Record{
-						{
-							ID:      "record2",
-							Name:    "www.zone2.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.2",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}, "group2": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "zone1.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.zone1.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-				{
-					Domain: "zone2.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.zone2.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.2",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			result := filterPeerAppliedZones(ctx, tt.accountZones, tt.peerGroups)
-			require.Equal(t, len(tt.expected), len(result), "number of custom zones should match")
-
-			for i, expectedZone := range tt.expected {
-				assert.Equal(t, expectedZone.Domain, result[i].Domain, "domain should match")
-				assert.Equal(t, expectedZone.SearchDomainDisabled, result[i].SearchDomainDisabled, "search domain disabled flag should match")
-				assert.Equal(t, len(expectedZone.Records), len(result[i].Records), "number of records should match")
-
-				for j, expectedRecord := range expectedZone.Records {
-					assert.Equal(t, expectedRecord.Name, result[i].Records[j].Name, "record name should match")
-					assert.Equal(t, expectedRecord.Type, result[i].Records[j].Type, "record type should match")
-					assert.Equal(t, expectedRecord.Class, result[i].Records[j].Class, "record class should match")
-					assert.Equal(t, expectedRecord.TTL, result[i].Records[j].TTL, "record TTL should match")
-					assert.Equal(t, expectedRecord.RData, result[i].Records[j].RData, "record RData should match")
-				}
-			}
-		})
-	}
-}
-
 func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
 	ctx := context.Background()
 
@@ -1564,6 +1052,7 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
 			Identifier: "net-1",
 			Net:        net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
 		},
+		Settings: &Settings{},
 		Peers: map[string]*nbpeer.Peer{
 			"user-peer": {
 				ID:        "user-peer",
@@ -1614,41 +1103,25 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
 		},
 	}
 
-	account.InjectProxyPolicies(ctx)
-
-	var found *Policy
-	for _, p := range account.Policies {
-		if p != nil && p.ID == "private-access-svc-1-proxy-peer" {
-			found = p
-			break
-		}
-	}
-	require.NotNil(t, found, "expected synthesised private-access policy in account.Policies")
+	found := findPolicy(injectedPolicies(account), "private-access-svc-1-proxy-peer")
+	require.NotNil(t, found, "expected synthesised private-access policy in the twin store")
 	require.Len(t, found.Rules, 1, "policy should have exactly one rule")
 	rule := found.Rules[0]
 	assert.Equal(t, []string{"grp-admins"}, rule.Sources, "sources should be group IDs verbatim")
 	assert.Equal(t, "proxy-peer", rule.DestinationResource.ID, "destination resource should be the proxy peer ID")
-	assert.Equal(t, ResourceTypePeer, rule.DestinationResource.Type, "destination resource type should be peer")
+	assert.Equal(t, string(ResourceTypePeer), rule.DestinationResource.Type, "destination resource type should be peer")
 
 	validatedPeersMap := map[string]struct{}{
 		"user-peer":  {},
 		"proxy-peer": {},
 	}
 
-	proxyPeer := account.Peers["proxy-peer"]
-	aclPeers, firewallRules, _, _ := account.GetPeerConnectionResources(ctx, proxyPeer, validatedPeersMap, nil)
+	nm := account.GetPeerNetworkMapFromComponents(ctx, "proxy-peer", nbdns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, nil)
 
-	var sawUserAsAclPeer bool
-	for _, p := range aclPeers {
-		if p.ID == "user-peer" {
-			sawUserAsAclPeer = true
-			break
-		}
-	}
-	assert.True(t, sawUserAsAclPeer, "proxy peer should see the user peer as an ACL peer")
+	assert.Contains(t, netmapPeerIDs(nm.Peers), "user-peer", "proxy peer should see the user peer as an ACL peer")
 
 	var inboundRules []*FirewallRule
-	for _, r := range firewallRules {
+	for _, r := range nm.FirewallRules {
 		if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() {
 			inboundRules = append(inboundRules, r)
 		}
@@ -1657,29 +1130,23 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
 }
 
 func TestInjectPrivateServicePolicies_NotPrivate_NoPolicy(t *testing.T) {
-	ctx := context.Background()
 	account := privateServiceTestAccount(t)
 	account.Services[0].Private = false
 
-	account.InjectProxyPolicies(ctx)
 	assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "non-private service must not synthesise an access policy")
 }
 
 func TestInjectPrivateServicePolicies_EmptyAccessGroups_NoPolicy(t *testing.T) {
-	ctx := context.Background()
 	account := privateServiceTestAccount(t)
 	account.Services[0].AccessGroups = nil
 
-	account.InjectProxyPolicies(ctx)
 	assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "private service with no access groups must not synthesise a policy")
 }
 
 func TestInjectPrivateServicePolicies_NoProxyPeers_NoPolicy(t *testing.T) {
-	ctx := context.Background()
 	account := privateServiceTestAccount(t)
 	delete(account.Peers, "proxy-peer")
 
-	account.InjectProxyPolicies(ctx)
 	assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "policy must not synthesise when the cluster has no proxy peers")
 }
 
@@ -1742,10 +1209,27 @@ func privateServiceTestAccount(t *testing.T) *Account {
 	}
 }
 
+// injectedPolicies returns the twin's policies with the synthesised proxy ACLs
+// already in place, the way the per-peer computation sees them.
+func injectedPolicies(account *Account) []*nmdata.Policy {
+	nmd := account.toNetworkMapData(nil, nil, nil, nil, nil)
+	nmd.InjectProxyPolicies()
+	return nmd.Policies
+}
+
+func findPolicy(policies []*nmdata.Policy, id string) *nmdata.Policy {
+	for _, p := range policies {
+		if p != nil && p.ID == id {
+			return p
+		}
+	}
+	return nil
+}
+
 func hasPrivateAccessPolicy(account *Account, serviceID string) bool {
 	prefix := "private-access-" + serviceID + "-"
-	for _, p := range account.Policies {
-		if p != nil && len(p.ID) > len(prefix) && p.ID[:len(prefix)] == prefix {
+	for _, p := range injectedPolicies(account) {
+		if p != nil && strings.HasPrefix(p.ID, prefix) {
 			return true
 		}
 	}
@@ -1781,41 +1265,45 @@ func TestForcesRoutingPeerDNSResolution(t *testing.T) {
 		return buildAccountRes(serviceEnabled, targetEnabled, resourceEnabled, targetType, resourceTypes.Domain)
 	}
 
+	forced := func(account *Account, peerID string) bool {
+		nmd := account.toNetworkMapData(nil, nil, nil, account.GetResourceRoutersMap(), nil)
+		return nmd.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{}).ForceRoutingPeerDNSResolution
+	}
+
 	t.Run("router peer for RP-targeted domain resource is forced", func(t *testing.T) {
 		account := buildAccount(true, true, true, service.TargetTypeDomain)
-		routers := account.GetResourceRoutersMap()
-		assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer", routers), "direct router peer should be forced")
-		assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer-grp", routers), "group-member router peer should be forced")
+		assert.True(t, forced(account, "router-peer"), "direct router peer should be forced")
+		assert.True(t, forced(account, "router-peer-grp"), "group-member router peer should be forced")
 	})
 
 	t.Run("non-router peer is not forced", func(t *testing.T) {
 		account := buildAccount(true, true, true, service.TargetTypeDomain)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("other-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "other-peer"))
 	})
 
 	t.Run("not forced when service disabled", func(t *testing.T) {
 		account := buildAccount(false, true, true, service.TargetTypeDomain)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "router-peer"))
 	})
 
 	t.Run("not forced when target disabled", func(t *testing.T) {
 		account := buildAccount(true, false, true, service.TargetTypeDomain)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "router-peer"))
 	})
 
 	t.Run("not forced when resource disabled", func(t *testing.T) {
 		account := buildAccount(true, true, false, service.TargetTypeDomain)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "router-peer"))
 	})
 
 	t.Run("not forced for non-domain target type", func(t *testing.T) {
 		account := buildAccount(true, true, true, service.TargetTypePeer)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "router-peer"))
 	})
 
 	t.Run("not forced when targeted resource is not a domain", func(t *testing.T) {
 		account := buildAccountRes(true, true, true, service.TargetTypeDomain, resourceTypes.Host)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()),
+		assert.False(t, forced(account, "router-peer"),
 			"a domain target pointing at a non-domain resource must not force resolution")
 	})
 }
diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go
index 9324cfa1e..452a2746d 100644
--- a/management/server/types/aliases.go
+++ b/management/server/types/aliases.go
@@ -2,54 +2,31 @@ package types
 
 import (
 	"context"
-	"math/rand"
-	"net"
-	"net/netip"
 
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
 )
 
 // Type aliases for types relocated to shared/management/types so that the
 // client-side compute path can depend on them
 
-type DNSSettings = sharedtypes.DNSSettings
-
 type FirewallRule = sharedtypes.FirewallRule
 
-type Network = sharedtypes.Network
 type NetworkMap = sharedtypes.NetworkMap
 type ForwardingRule = sharedtypes.ForwardingRule
 
-type Policy = sharedtypes.Policy
-type PolicyUpdateOperation = sharedtypes.PolicyUpdateOperation
-
-type PolicyRule = sharedtypes.PolicyRule
-type PolicyUpdateOperationType = sharedtypes.PolicyUpdateOperationType
 type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType
 type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType
-type PolicyRuleDirection = sharedtypes.PolicyRuleDirection
 type RulePortRange = sharedtypes.RulePortRange
 
-type Resource = sharedtypes.Resource
 type ResourceType = sharedtypes.ResourceType
 
 type RouteFirewallRule = sharedtypes.RouteFirewallRule
 
 type NetworkMapComponents = sharedtypes.NetworkMapComponents
 
-type ComponentPeer = sharedtypes.ComponentPeer
-type ComponentGroup = sharedtypes.ComponentGroup
-type ComponentRouter = sharedtypes.ComponentRouter
-type ComponentResource = sharedtypes.ComponentResource
-type ComponentResourceType = sharedtypes.ComponentResourceType
-
-const (
-	ComponentResourceHost   = sharedtypes.ComponentResourceHost
-	ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet
-	ComponentResourceDomain = sharedtypes.ComponentResourceDomain
-)
-
 var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents
 
 type AccountSettingsInfo = sharedtypes.AccountSettingsInfo
@@ -60,54 +37,36 @@ type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact
 type LookupMap = sharedtypes.LookupMap
 type FirewallRuleContext = sharedtypes.FirewallRuleContext
 
-const GroupAllName = sharedtypes.GroupAllName
-
 // Function forwarders preserve types.X(...) call sites that previously
 // resolved to package-local funcs. Plain forwarders (not var aliases) keep
 // the symbol immutable and allow the inliner to flatten the call.
 
+func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) {
+	return sharedtypes.ParseRuleString(rule)
+}
+
 func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
-	return sharedtypes.PolicyRuleImpliesLegacySSH(rule)
+	return nmdata.PolicyRuleImpliesLegacySSH(twinRule(rule))
 }
 
-func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule {
-	return sharedtypes.ExpandPortsAndRanges(base, rule, peer)
+// ExpandPortsAndRanges / AppendIPv6FirewallRule / GenerateRouteFirewallRules
+// forward to the shared twin-typed helpers, converting the real types the
+// legacy Account calc still uses to nmdata twins at this boundary.
+
+func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule {
+	return sharedtypes.ExpandPortsAndRanges(base, twinRule(rule), twinPeer(peer))
 }
 
-func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
-	return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc)
+func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
+	return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, twinPeer(peer), twinPeer(targetPeer), twinRule(rule), rc)
 }
 
 func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap {
 	return sharedtypes.CalculateNetworkMapFromComponents(ctx, components)
 }
 
-func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule {
-	return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6)
-}
-
-func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
-	return sharedtypes.AllocateIPv6Subnet(r)
-}
-
-func NewNetwork() *Network {
-	return sharedtypes.NewNetwork()
-}
-
-func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
-	return sharedtypes.AllocatePeerIP(prefix, takenIps)
-}
-
-func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
-	return sharedtypes.AllocateRandomPeerIP(prefix)
-}
-
-func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
-	return sharedtypes.AllocateRandomPeerIPv6(prefix)
-}
-
-func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) {
-	return sharedtypes.ParseRuleString(rule)
+func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule {
+	return sharedtypes.GenerateRouteFirewallRules(ctx, twinRoute(route), twinRule(rule), TwinPeers(groupPeers), direction, includeIPv6)
 }
 
 const (
@@ -115,6 +74,11 @@ const (
 	FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT
 )
 
+const (
+	AllowedIPsFormat   = sharedtypes.AllowedIPsFormat
+	AllowedIPsV6Format = sharedtypes.AllowedIPsV6Format
+)
+
 const (
 	ResourceTypePeer   = sharedtypes.ResourceTypePeer
 	ResourceTypeDomain = sharedtypes.ResourceTypeDomain
@@ -134,15 +98,3 @@ const (
 	PolicyRuleProtocolICMP       = sharedtypes.PolicyRuleProtocolICMP
 	PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH
 )
-
-const (
-	PolicyRuleFlowDirect   = sharedtypes.PolicyRuleFlowDirect
-	PolicyRuleFlowBidirect = sharedtypes.PolicyRuleFlowBidirect
-)
-
-const (
-	DefaultRuleName          = sharedtypes.DefaultRuleName
-	DefaultRuleDescription   = sharedtypes.DefaultRuleDescription
-	DefaultPolicyName        = sharedtypes.DefaultPolicyName
-	DefaultPolicyDescription = sharedtypes.DefaultPolicyDescription
-)
diff --git a/shared/management/types/dns_settings.go b/management/server/types/dns_settings.go
similarity index 100%
rename from shared/management/types/dns_settings.go
rename to management/server/types/dns_settings.go
diff --git a/management/server/types/group.go b/management/server/types/group.go
index a5e196997..ac0a2a7f2 100644
--- a/management/server/types/group.go
+++ b/management/server/types/group.go
@@ -1,7 +1,8 @@
 package types
 
 import (
-	"github.com/netbirdio/netbird/management/server/integration_reference"
+	"github.com/netbirdio/netbird/shared/management/integration_reference"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 const (
@@ -67,6 +68,10 @@ func (g *Group) EventMeta() map[string]any {
 	return map[string]any{"name": g.Name}
 }
 
+func (g *Group) EventMetaResource(resource *nmdata.NetworkResource) map[string]any {
+	return map[string]any{"name": g.Name, "id": g.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type}
+}
+
 func (g *Group) Copy() *Group {
 	group := &Group{
 		ID:                   g.ID,
@@ -90,39 +95,14 @@ func (g *Group) HasPeers() bool {
 	return len(g.Peers) > 0
 }
 
+// GroupAllName is the reserved name of the default group that contains every peer in an account.
+const GroupAllName = "All"
+
 // IsGroupAll checks if the group is a default "All" group.
 func (g *Group) IsGroupAll() bool {
 	return g.Name == GroupAllName
 }
 
-// ToComponent converts the group to its self-contained components
-// representation. The Peers slice is shared, not copied — components are
-// treated as immutable snapshots. Returns nil for a nil group.
-func (g *Group) ToComponent() *ComponentGroup {
-	if g == nil {
-		return nil
-	}
-	return &ComponentGroup{
-		ID:       g.ID,
-		PublicID: g.PublicID,
-		Name:     g.Name,
-		Peers:    g.Peers,
-	}
-}
-
-// GroupsToComponent converts an id-keyed group map to its components
-// representation, preserving nil entries.
-func GroupsToComponent(groups map[string]*Group) map[string]*ComponentGroup {
-	if groups == nil {
-		return nil
-	}
-	out := make(map[string]*ComponentGroup, len(groups))
-	for id, g := range groups {
-		out[id] = g.ToComponent()
-	}
-	return out
-}
-
 // AddPeer adds peerID to Peers if not present, returning true if added.
 func (g *Group) AddPeer(peerID string) bool {
 	if peerID == "" {
diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go
index d83603abe..76c61369e 100644
--- a/management/server/types/ipv6_endtoend_test.go
+++ b/management/server/types/ipv6_endtoend_test.go
@@ -9,7 +9,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) {
@@ -105,7 +105,7 @@ func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) {
 	require.NotNil(t, nm)
 
 	t.Run("AllowedIPs include remote v6", func(t *testing.T) {
-		var dst *types.ComponentPeer
+		var dst *nmdata.Peer
 		for _, p := range nm.Peers {
 			if p.ID == "peer-dst-1" {
 				dst = p
diff --git a/management/server/types/legacynmap/account_components.go b/management/server/types/legacynmap/account_components.go
new file mode 100644
index 000000000..5d5b4a9cf
--- /dev/null
+++ b/management/server/types/legacynmap/account_components.go
@@ -0,0 +1,701 @@
+package legacynmap
+
+import (
+	"context"
+	"slices"
+	"time"
+
+	log "github.com/sirupsen/logrus"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+	"github.com/netbirdio/netbird/management/server/telemetry"
+	"github.com/netbirdio/netbird/route"
+)
+
+// GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or
+// the components path based on the peer's capability and the kill switch.
+// Capable peers (PeerCapabilityComponentNetworkMap) get the raw components
+// shape — the server skips Calculate() entirely for them, saving CPU
+// proportional to the number of capable peers in the account. Legacy peers
+// (or any peer when componentsDisabled is true) get the fully-expanded
+// NetworkMap as before.
+
+func GetPeerNetworkMapFromComponents(a *Account,
+	ctx context.Context,
+	peerID string,
+	peersCustomZone nbdns.CustomZone,
+	accountZones []*zones.Zone,
+	validatedPeersMap map[string]struct{},
+	resourcePolicies map[string][]*Policy,
+	routers map[string]map[string]*routerTypes.NetworkRouter,
+	metrics *telemetry.AccountManagerMetrics,
+	groupIDToUserIDs map[string][]string,
+) *NetworkMap {
+	start := time.Now()
+
+	components := GetPeerNetworkMapComponents(a,
+		ctx,
+		peerID,
+		peersCustomZone,
+		accountZones,
+		validatedPeersMap,
+		resourcePolicies,
+		routers,
+		groupIDToUserIDs,
+	)
+
+	if components.IsEmpty() {
+		return &NetworkMap{Network: components.Network}
+	}
+
+	nm := CalculateNetworkMapFromComponents(ctx, components)
+
+	if metrics != nil {
+		objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
+		metrics.CountNetworkMapObjects(objectCount)
+		metrics.CountGetPeerNetworkMapDuration(time.Since(start))
+
+		if objectCount > 5000 {
+			log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects from components, "+
+				"peers: %d, offline peers: %d, routes: %d, firewall rules: %d, route firewall rules: %d",
+				a.Id, objectCount, len(nm.Peers), len(nm.OfflinePeers), len(nm.Routes), len(nm.FirewallRules), len(nm.RoutesFirewallRules))
+		}
+	}
+
+	return nm
+}
+
+func GetPeerNetworkMapComponents(a *Account,
+	ctx context.Context,
+	peerID string,
+	peersCustomZone nbdns.CustomZone,
+	accountZones []*zones.Zone,
+	validatedPeersMap map[string]struct{},
+	resourcePolicies map[string][]*Policy,
+	routers map[string]map[string]*routerTypes.NetworkRouter,
+	groupIDToUserIDs map[string][]string,
+) *NetworkMapComponents {
+	peer := a.Peers[peerID]
+	// this can never happen, things are very wrong if it did
+	// TODO (dmitri) maybe consider using invariants?
+	if peer == nil {
+		log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account")
+		return EmptyNetworkMapComponents(&NetworkMapComponents{
+			PeerID:  peerID,
+			Network: a.Network.Copy(),
+			// must include the target peer as it's required on the client
+			Peers: map[string]*ComponentPeer{peerID: peerToComponent(peer)},
+		})
+	}
+
+	if _, ok := validatedPeersMap[peerID]; !ok {
+		// Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents
+		// returns &NetworkMap{Network: a.Network.Copy()} when components is
+		// nil. Match that floor so the receiving client always sees the
+		// account Network identifier, not a fully-empty envelope.
+		return EmptyNetworkMapComponents(&NetworkMapComponents{
+			PeerID:  peerID,
+			Network: a.Network.Copy(),
+			// must include the target peer as it's required on the client
+			Peers: map[string]*ComponentPeer{peerID: peerToComponent(peer)},
+		})
+	}
+
+	components := &NetworkMapComponents{
+		PeerID:                    peerID,
+		Network:                   a.Network.Copy(),
+		NameServerGroups:          make([]*nbdns.NameServerGroup, 0),
+		CustomZoneDomain:          peersCustomZone.Domain,
+		ResourcePoliciesMap:       make(map[string][]*Policy),
+		RoutersMap:                make(map[string]map[string]*ComponentRouter),
+		NetworkResources:          make([]*ComponentResource, 0),
+		PostureFailedPeers:        make(map[string]map[string]struct{}, len(a.PostureChecks)),
+		RouterPeers:               make(map[string]*ComponentPeer),
+		NetworkXIDToPublicID:      make(map[string]string, len(a.Networks)),
+		PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
+
+		ForceRoutingPeerDNSResolution: forcesRoutingPeerDNSResolution(a, peerID, routers),
+	}
+	for _, n := range a.Networks {
+		if n != nil {
+			components.NetworkXIDToPublicID[n.ID] = n.PublicID
+		}
+	}
+	for _, pc := range a.PostureChecks {
+		if pc != nil {
+			components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
+		}
+	}
+
+	components.AccountSettings = &AccountSettingsInfo{
+		PeerLoginExpirationEnabled:      a.Settings.PeerLoginExpirationEnabled,
+		PeerLoginExpiration:             a.Settings.PeerLoginExpiration,
+		PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled,
+		PeerInactivityExpiration:        a.Settings.PeerInactivityExpiration,
+	}
+
+	components.DNSSettings = &a.DNSSettings
+
+	// relevantPeers always contains the target peer (peerID)
+	relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := getPeersGroupsPoliciesRoutes(a, ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers)
+
+	if len(sshReqs.neededGroupIDs) > 0 {
+		components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs)
+	}
+	if sshReqs.needAllowedUserIDs {
+		components.AllowedUserIDs = getAllowedUserIDs(a)
+	}
+
+	components.Peers = relevantPeers
+	components.Groups = groupsToComponent(relevantGroups)
+	components.Policies = relevantPolicies
+	components.Routes = relevantRoutes
+	components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
+
+	peerGroups := a.GetPeerGroups(peerID)
+	components.AccountZones = filterPeerAppliedZones(ctx, accountZones, LookupMap(peerGroups))
+	components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...)
+
+	for _, nsGroup := range a.NameServerGroups {
+		if nsGroup.Enabled {
+			for _, gID := range nsGroup.Groups {
+				if _, found := relevantGroups[gID]; found {
+					components.NameServerGroups = append(components.NameServerGroups, nsGroup)
+					break
+				}
+			}
+		}
+	}
+
+	for _, resource := range a.NetworkResources {
+		if !resource.Enabled {
+			continue
+		}
+
+		policies, exists := resourcePolicies[resource.ID]
+		if !exists {
+			continue
+		}
+
+		addSourcePeers := false
+
+		networkRoutingPeers, routerExists := routers[resource.NetworkID]
+		if routerExists {
+			if _, ok := networkRoutingPeers[peerID]; ok {
+				addSourcePeers = true
+			}
+		}
+
+		for _, policy := range policies {
+			if addSourcePeers {
+				var peers []string
+				if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
+					peers = []string{policy.Rules[0].SourceResource.ID}
+				} else {
+					peers = getUniquePeerIDsFromGroupsIDs(a, ctx, policy.SourceGroups())
+				}
+				for _, pID := range getPostureValidPeersSaveFailed(a, peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) {
+					if _, exists := components.Peers[pID]; !exists {
+						components.Peers[pID] = peerToComponent(a.GetPeer(pID))
+					}
+				}
+			} else {
+				peerInSources := false
+				if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
+					peerInSources = policy.Rules[0].SourceResource.ID == peerID
+				} else {
+					for _, groupID := range policy.SourceGroups() {
+						if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) {
+							peerInSources = true
+							break
+						}
+					}
+				}
+				if !peerInSources {
+					continue
+				}
+				isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, policy.SourcePostureChecks, peerID)
+				if !isValid && len(pname) > 0 {
+					if _, ok := components.PostureFailedPeers[pname]; !ok {
+						components.PostureFailedPeers[pname] = make(map[string]struct{})
+					}
+					components.PostureFailedPeers[pname][peer.ID] = struct{}{}
+					continue
+				}
+				addSourcePeers = true
+			}
+
+			for _, rule := range policy.Rules {
+				for _, srcGroupID := range rule.Sources {
+					if g := a.Groups[srcGroupID]; g != nil {
+						if _, exists := components.Groups[srcGroupID]; !exists {
+							components.Groups[srcGroupID] = groupToComponent(g)
+						}
+					}
+				}
+				for _, dstGroupID := range rule.Destinations {
+					if g := a.Groups[dstGroupID]; g != nil {
+						if _, exists := components.Groups[dstGroupID]; !exists {
+							components.Groups[dstGroupID] = groupToComponent(g)
+						}
+					}
+				}
+			}
+			components.ResourcePoliciesMap[resource.ID] = policies
+		}
+
+		// Only expose router peers and the per-network routers_map when this
+		// target peer actually has access to the resource (either as a router
+		// itself or via a policy that includes it as a source). Without this
+		// gate, every peer's envelope was leaking router peers of every
+		// network in the account — accounts with many tenants/networks
+		// shipped tens of unrelated peers in `peers[]` and `routers_map`.
+		if addSourcePeers {
+			components.RoutersMap[resource.NetworkID] = routersToComponentMap(networkRoutingPeers)
+			for peerIDKey := range networkRoutingPeers {
+				if p := a.Peers[peerIDKey]; p != nil {
+					cp := components.RouterPeers[peerIDKey]
+					if cp == nil {
+						cp = peerToComponent(p)
+						components.RouterPeers[peerIDKey] = cp
+					}
+					if _, exists := components.Peers[peerIDKey]; !exists {
+						if _, validated := validatedPeersMap[peerIDKey]; validated {
+							components.Peers[peerIDKey] = cp
+						}
+					}
+				}
+			}
+			components.NetworkResources = append(components.NetworkResources, resourceToComponent(resource))
+		}
+	}
+
+	filterGroupPeers(&components.Groups, components.Peers)
+	filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers)
+
+	return components
+}
+
+type sshRequirements struct {
+	neededGroupIDs     map[string]struct{}
+	needAllowedUserIDs bool
+}
+
+func getPeersGroupsPoliciesRoutes(a *Account,
+	ctx context.Context,
+	peerID string,
+	peerSSHEnabled bool,
+	validatedPeersMap map[string]struct{},
+	postureFailedPeers *map[string]map[string]struct{},
+) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) {
+	relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4)
+	relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4)
+	relevantPolicies := make([]*Policy, 0, len(a.Policies))
+	relevantRoutes := make([]*route.Route, 0, len(a.Routes))
+	sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})}
+
+	relevantPeerIDs[peerID] = peerToComponent(a.GetPeer(peerID))
+
+	peerGroupSet := make(map[string]struct{}, 8)
+	for groupID, group := range a.Groups {
+		if slices.Contains(group.Peers, peerID) {
+			relevantGroupIDs[groupID] = a.GetGroup(groupID)
+			peerGroupSet[groupID] = struct{}{}
+		}
+	}
+
+	routeAccessControlGroups := make(map[string]struct{})
+	for _, r := range a.Routes {
+		if r == nil {
+			continue
+		}
+		relevant := r.Peer == peerID
+		if !relevant {
+			for _, groupID := range r.PeerGroups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant && r.Enabled {
+			for _, groupID := range r.Groups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant {
+			continue
+		}
+
+		for _, groupID := range r.PeerGroups {
+			relevantGroupIDs[groupID] = a.GetGroup(groupID)
+		}
+		for _, groupID := range r.Groups {
+			relevantGroupIDs[groupID] = a.GetGroup(groupID)
+		}
+		if r.Enabled {
+			for _, groupID := range r.AccessControlGroups {
+				relevantGroupIDs[groupID] = a.GetGroup(groupID)
+				routeAccessControlGroups[groupID] = struct{}{}
+			}
+		}
+
+		// Include route advertisers in relevantPeerIDs. The envelope
+		// encoder writes route.peer_index by looking up r.Peer in the
+		// shipped peers list; if the advertiser is policy-isolated from
+		// the target peer (no rule edge between them), it would otherwise
+		// be omitted and the decoder would fail to resolve r.Peer, leaving
+		// the client without a WG tunnel target for this route. Legacy
+		// NetworkMap.Routes shipped the WG public key inline, so the
+		// equivalence path doesn't surface this — but the dependency is
+		// real once a client actually tries to use the route.
+		// Gate by validatedPeersMap so non-validated advertisers stay out
+		// (matches the network-resource router behaviour at the bottom of
+		// this loop, and the legacy invariant that only validated peers
+		// reach a client's view).
+		if r.Peer != "" {
+			if _, ok := validatedPeersMap[r.Peer]; ok {
+				if p := a.GetPeer(r.Peer); p != nil {
+					relevantPeerIDs[r.Peer] = peerToComponent(p)
+				}
+			}
+		}
+		for _, groupID := range r.PeerGroups {
+			g := a.GetGroup(groupID)
+			if g == nil {
+				continue
+			}
+			for _, pid := range g.Peers {
+				if _, exists := relevantPeerIDs[pid]; exists {
+					continue
+				}
+				if _, ok := validatedPeersMap[pid]; !ok {
+					continue
+				}
+				if p := a.GetPeer(pid); p != nil {
+					relevantPeerIDs[pid] = peerToComponent(p)
+				}
+			}
+		}
+		relevantRoutes = append(relevantRoutes, r)
+	}
+
+	for _, policy := range a.Policies {
+		if !policy.Enabled {
+			continue
+		}
+
+		policyRelevant := false
+		for _, rule := range policy.Rules {
+			if !rule.Enabled {
+				continue
+			}
+
+			if len(routeAccessControlGroups) > 0 {
+				for _, destGroupID := range rule.Destinations {
+					if _, needed := routeAccessControlGroups[destGroupID]; needed {
+						policyRelevant = true
+						for _, srcGroupID := range rule.Sources {
+							relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
+						}
+						for _, dstGroupID := range rule.Destinations {
+							relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
+						}
+						break
+					}
+				}
+			}
+
+			var sourcePeers, destinationPeers []string
+			var peerInSources, peerInDestinations bool
+
+			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+				sourcePeers = []string{rule.SourceResource.ID}
+				if rule.SourceResource.ID == peerID {
+					peerInSources = true
+				}
+			} else {
+				sourcePeers, peerInSources = getPeersFromGroups(a, ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers)
+			}
+
+			if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
+				destinationPeers = []string{rule.DestinationResource.ID}
+				if rule.DestinationResource.ID == peerID {
+					peerInDestinations = true
+				}
+			} else {
+				destinationPeers, peerInDestinations = getPeersFromGroups(a, ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers)
+			}
+
+			if peerInSources {
+				policyRelevant = true
+				for _, pid := range destinationPeers {
+					if _, exists := relevantPeerIDs[pid]; !exists {
+						relevantPeerIDs[pid] = peerToComponent(a.GetPeer(pid))
+					}
+				}
+				for _, dstGroupID := range rule.Destinations {
+					relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
+				}
+			}
+
+			if peerInDestinations {
+				policyRelevant = true
+				for _, pid := range sourcePeers {
+					if _, exists := relevantPeerIDs[pid]; !exists {
+						relevantPeerIDs[pid] = peerToComponent(a.GetPeer(pid))
+					}
+				}
+				for _, srcGroupID := range rule.Sources {
+					relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
+				}
+
+				if rule.Protocol == PolicyRuleProtocolNetbirdSSH {
+					switch {
+					case len(rule.AuthorizedGroups) > 0:
+						for groupID := range rule.AuthorizedGroups {
+							sshReqs.neededGroupIDs[groupID] = struct{}{}
+						}
+					case rule.AuthorizedUser != "":
+					default:
+						sshReqs.needAllowedUserIDs = true
+					}
+				} else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled {
+					sshReqs.needAllowedUserIDs = true
+				}
+			}
+		}
+		if policyRelevant {
+			relevantPolicies = append(relevantPolicies, policy)
+		}
+	}
+
+	return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs
+}
+
+func getPeersFromGroups(a *Account, ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string,
+	validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
+	peerInGroups := false
+	filteredPeerIDs := make([]string, 0, len(groups))
+	seenPeerIds := make(map[string]struct{}, len(groups))
+
+	for _, gid := range groups {
+		group := a.GetGroup(gid)
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			filteredPeerIDs = make([]string, 0, len(group.Peers))
+			peerInGroups = false
+			for _, pid := range group.Peers {
+				peer, ok := a.Peers[pid]
+				if !ok || peer == nil {
+					continue
+				}
+
+				if _, ok := validatedPeersMap[peer.ID]; !ok {
+					continue
+				}
+
+				isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, sourcePostureChecksIDs, peer.ID)
+				if !isValid && len(pname) > 0 {
+					if _, ok := (*postureFailedPeers)[pname]; !ok {
+						(*postureFailedPeers)[pname] = make(map[string]struct{})
+					}
+					(*postureFailedPeers)[pname][peer.ID] = struct{}{}
+					continue
+				}
+
+				if peer.ID == peerID {
+					peerInGroups = true
+					continue
+				}
+
+				filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+			}
+			return filteredPeerIDs, peerInGroups
+		}
+
+		for _, pid := range group.Peers {
+			if _, seen := seenPeerIds[pid]; seen {
+				continue
+			}
+			seenPeerIds[pid] = struct{}{}
+			peer, ok := a.Peers[pid]
+			if !ok || peer == nil {
+				continue
+			}
+
+			if _, ok := validatedPeersMap[peer.ID]; !ok {
+				continue
+			}
+
+			isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, sourcePostureChecksIDs, peer.ID)
+			if !isValid && len(pname) > 0 {
+				if _, ok := (*postureFailedPeers)[pname]; !ok {
+					(*postureFailedPeers)[pname] = make(map[string]struct{})
+				}
+				(*postureFailedPeers)[pname][peer.ID] = struct{}{}
+				continue
+			}
+
+			if peer.ID == peerID {
+				peerInGroups = true
+				continue
+			}
+
+			filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+		}
+	}
+
+	return filteredPeerIDs, peerInGroups
+}
+
+func validatePostureChecksOnPeerGetFailed(a *Account, ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) {
+	peer, ok := a.Peers[peerID]
+	if !ok || peer == nil {
+		return false, ""
+	}
+
+	for _, postureChecksID := range sourcePostureChecksID {
+		postureChecks := a.GetPostureChecks(postureChecksID)
+		if postureChecks == nil {
+			continue
+		}
+
+		for _, check := range postureChecks.GetChecks() {
+			isValid, _ := check.Check(ctx, *peer)
+			if !isValid {
+				return false, postureChecksID
+			}
+		}
+	}
+	return true, ""
+}
+
+func getPostureValidPeersSaveFailed(a *Account, inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string {
+	var dest []string
+	for _, peerID := range inputPeers {
+		if _, validated := validatedPeersMap[peerID]; !validated {
+			continue
+		}
+		valid, pname := validatePostureChecksOnPeerGetFailed(a, context.Background(), postureChecksIDs, peerID)
+		if valid {
+			dest = append(dest, peerID)
+			continue
+		}
+		if _, ok := (*postureFailedPeers)[pname]; !ok {
+			(*postureFailedPeers)[pname] = make(map[string]struct{})
+		}
+		(*postureFailedPeers)[pname][peerID] = struct{}{}
+	}
+	return dest
+}
+
+// filterGroupPeers trims each group's Peers slice to only those peers that
+// also appear in `peers`. Groups whose filtered list is empty are NOT
+// deleted from the map — they're kept so the components wire encoder can
+// still resolve seq references from routes/policies/access-control groups
+// that name them. Calculate() tolerates groups with empty Peers (the inner
+// loops simply iterate zero times), so retaining them is behaviourally a
+// no-op for the legacy path that consumes the same NetworkMapComponents.
+func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) {
+	for groupID, groupInfo := range *groups {
+		filteredPeers := make([]string, 0, len(groupInfo.Peers))
+		for _, pid := range groupInfo.Peers {
+			if _, exists := peers[pid]; exists {
+				filteredPeers = append(filteredPeers, pid)
+			}
+		}
+
+		if len(filteredPeers) != len(groupInfo.Peers) {
+			ng := *groupInfo
+			ng.Peers = filteredPeers
+			(*groups)[groupID] = &ng
+		}
+	}
+}
+
+func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) {
+	if len(*postureFailedPeers) == 0 {
+		return
+	}
+
+	referencedPostureChecks := make(map[string]struct{})
+	for _, policy := range policies {
+		for _, checkID := range policy.SourcePostureChecks {
+			referencedPostureChecks[checkID] = struct{}{}
+		}
+	}
+	for _, resPolicies := range resourcePoliciesMap {
+		for _, policy := range resPolicies {
+			for _, checkID := range policy.SourcePostureChecks {
+				referencedPostureChecks[checkID] = struct{}{}
+			}
+		}
+	}
+
+	for checkID, failedPeers := range *postureFailedPeers {
+		if _, referenced := referencedPostureChecks[checkID]; !referenced {
+			delete(*postureFailedPeers, checkID)
+			continue
+		}
+		for peerID := range failedPeers {
+			if _, exists := peers[peerID]; !exists {
+				delete(failedPeers, peerID)
+			}
+		}
+		if len(failedPeers) == 0 {
+			delete(*postureFailedPeers, checkID)
+		}
+	}
+}
+
+func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord {
+	if len(records) == 0 || len(peers) == 0 {
+		return nil
+	}
+
+	// Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6
+	// address) are not filtered out when peers have IPv6 assigned. When the
+	// requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped.
+	peerIPs := make(map[string]struct{}, len(peers)*2)
+	for _, peer := range peers {
+		if peer == nil {
+			continue
+		}
+		peerIPs[peer.IP.String()] = struct{}{}
+		if includeIPv6 && peer.IPv6.IsValid() {
+			peerIPs[peer.IPv6.String()] = struct{}{}
+		}
+	}
+
+	filteredRecords := make([]nbdns.SimpleRecord, 0, len(records))
+	for _, record := range records {
+		if _, exists := peerIPs[record.RData]; exists {
+			filteredRecords = append(filteredRecords, record)
+		}
+	}
+
+	return filteredRecords
+}
+
+func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string {
+	if len(neededGroupIDs) == 0 {
+		return nil
+	}
+
+	filtered := make(map[string][]string, len(neededGroupIDs))
+	for groupID := range neededGroupIDs {
+		if users, ok := fullMap[groupID]; ok {
+			filtered[groupID] = users
+		}
+	}
+	return filtered
+}
diff --git a/management/server/types/legacynmap/aliases.go b/management/server/types/legacynmap/aliases.go
new file mode 100644
index 000000000..82a18192b
--- /dev/null
+++ b/management/server/types/legacynmap/aliases.go
@@ -0,0 +1,35 @@
+package legacynmap
+
+import (
+	types "github.com/netbirdio/netbird/management/server/types"
+	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+type (
+	Account = types.Account
+
+	DNSSettings       = types.DNSSettings
+	FirewallRule      = sharedtypes.FirewallRule
+	ForwardingRule    = sharedtypes.ForwardingRule
+	Group             = types.Group
+	Network           = types.Network
+	Policy            = types.Policy
+	PolicyRule        = types.PolicyRule
+	Resource          = types.Resource
+	RulePortRange     = sharedtypes.RulePortRange
+	RouteFirewallRule = sharedtypes.RouteFirewallRule
+)
+
+const (
+	FirewallRuleDirectionIN  = sharedtypes.FirewallRuleDirectionIN
+	FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT
+
+	PolicyRuleProtocolALL        = sharedtypes.PolicyRuleProtocolALL
+	PolicyRuleProtocolTCP        = sharedtypes.PolicyRuleProtocolTCP
+	PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH
+	PolicyTrafficActionAccept    = sharedtypes.PolicyTrafficActionAccept
+	ResourceTypePeer             = sharedtypes.ResourceTypePeer
+
+	AllowedIPsFormat   = sharedtypes.AllowedIPsFormat
+	AllowedIPsV6Format = sharedtypes.AllowedIPsV6Format
+)
diff --git a/management/server/types/legacynmap/benchmark_test.go b/management/server/types/legacynmap/benchmark_test.go
new file mode 100644
index 000000000..22e291e00
--- /dev/null
+++ b/management/server/types/legacynmap/benchmark_test.go
@@ -0,0 +1,350 @@
+//go:build nmapequiv
+
+// Account-load benchmark: the legacy store.GetAccount hydration (pgx fast
+// path, as in production) vs the nmdata store's GetNetworkMapData, against the
+// same Postgres copy as the equivalence test.
+//
+//	NETBIRD_STORE_ENGINE_POSTGRES_DSN='...' go test -tags nmapequiv \
+//	  -run '^$' -bench . -benchtime 5x -timeout 60m \
+//	  ./management/server/types/legacynmap/
+//
+// NETMAP_ACCOUNTS selects the accounts (comma-separated); by default the ten
+// accounts with the most peers are used. Each account is a sub-benchmark, so
+// the two paths can be compared per account. One warmup call runs untimed
+// before each measurement so Postgres buffer-cache state is comparable.
+//
+// Reported metrics beyond ns/op and allocs:
+//
+//   - queries/op    round trips, counted client-side via a pgx tracer
+//     (GetNetworkMapData only — the legacy store's pool is internal)
+//   - xact/op       committed transactions from pg_stat_database; the legacy
+//     pgx path runs autocommit statements, so this approximates its round
+//     trips, while GetNetworkMapData runs a single transaction
+//   - tup_returned/op, tup_fetched/op   rows scanned/fetched server-side
+//   - blks_read/op, blks_hit/op         buffer cache misses/hits
+//
+// The pg_stat_database numbers are database-global: run without concurrent
+// load. The two stat snapshots per sub-benchmark add a small constant
+// overhead to the server-side deltas.
+package legacynmap_test
+
+import (
+	"context"
+	"os"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"github.com/stretchr/testify/require"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+	networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
+	mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+	"github.com/netbirdio/netbird/management/server/store"
+	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+func BenchmarkGetAccount(b *testing.B) {
+	dsn := equivDSN()
+	if dsn == "" {
+		b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
+	}
+	ctx := context.Background()
+
+	statsConn, err := pgx.Connect(ctx, dsn)
+	require.NoError(b, err, "connect stats connection")
+	b.Cleanup(func() { statsConn.Close(ctx) })
+
+	testStore, err := store.NewPostgresqlStore(ctx, dsn, nil, true)
+	require.NoError(b, err, "connect to postgres")
+	b.Cleanup(func() { testStore.Close(ctx) })
+
+	for _, accountID := range benchAccountIDs(b, ctx, statsConn) {
+		b.Run(accountID, func(b *testing.B) {
+			logAccountShape(b, ctx, statsConn, accountID)
+			benchDBLoad(b, ctx, statsConn, nil, func() error {
+				_, err := testStore.GetAccount(ctx, accountID)
+				return err
+			})
+		})
+	}
+}
+
+func BenchmarkGetNetworkMapData(b *testing.B) {
+	dsn := equivDSN()
+	if dsn == "" {
+		b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
+	}
+	ctx := context.Background()
+
+	statsConn, err := pgx.Connect(ctx, dsn)
+	require.NoError(b, err, "connect stats connection")
+	b.Cleanup(func() { statsConn.Close(ctx) })
+
+	tracer := &queryCountTracer{}
+	cfg, err := pgxpool.ParseConfig(dsn)
+	require.NoError(b, err, "parse dsn")
+	cfg.ConnConfig.Tracer = tracer
+	pool, err := pgxpool.NewWithConfig(ctx, cfg)
+	require.NoError(b, err, "connect nmdata store")
+	b.Cleanup(pool.Close)
+	nmStore := nmDataStore(b, &networkmap_pgsql.PgStore{Pool: pool})
+
+	for _, accountID := range benchAccountIDs(b, ctx, statsConn) {
+		b.Run(accountID, func(b *testing.B) {
+			logAccountShape(b, ctx, statsConn, accountID)
+			benchDBLoad(b, ctx, statsConn, tracer, func() error {
+				_, err := nmStore.GetNetworkMapData(ctx, accountID)
+				return err
+			})
+		})
+	}
+}
+
+// BenchmarkAccountFullRound measures store load plus the full per-peer fan-out
+// to *proto.SyncResponse for every peer of the account, the way the production
+// account path runs it: index maps and per-peer twin building happen after
+// GetAccount and are part of the measured op. BenchmarkNetworkMapDataFullRound
+// is the equivalent for the nmdata path, whose index building happens inside
+// GetNetworkMapData. Select both with -bench FullRound.
+func BenchmarkAccountFullRound(b *testing.B) {
+	dsn := equivDSN()
+	if dsn == "" {
+		b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
+	}
+	ctx := context.Background()
+
+	statsConn, err := pgx.Connect(ctx, dsn)
+	require.NoError(b, err, "connect stats connection")
+	b.Cleanup(func() { statsConn.Close(ctx) })
+
+	testStore, err := store.NewPostgresqlStore(ctx, dsn, nil, true)
+	require.NoError(b, err, "connect to postgres")
+	b.Cleanup(func() { testStore.Close(ctx) })
+
+	for _, accountID := range benchAccountIDs(b, ctx, statsConn) {
+		b.Run(accountID, func(b *testing.B) {
+			logAccountShape(b, ctx, statsConn, accountID)
+			benchDBLoad(b, ctx, statsConn, nil, func() error {
+				account, err := testStore.GetAccount(ctx, accountID)
+				if err != nil {
+					return err
+				}
+				buildAccountSyncResponses(ctx, account)
+				return nil
+			})
+		})
+	}
+}
+
+func BenchmarkNetworkMapDataFullRound(b *testing.B) {
+	dsn := equivDSN()
+	if dsn == "" {
+		b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
+	}
+	ctx := context.Background()
+
+	statsConn, err := pgx.Connect(ctx, dsn)
+	require.NoError(b, err, "connect stats connection")
+	b.Cleanup(func() { statsConn.Close(ctx) })
+
+	tracer := &queryCountTracer{}
+	cfg, err := pgxpool.ParseConfig(dsn)
+	require.NoError(b, err, "parse dsn")
+	cfg.ConnConfig.Tracer = tracer
+	pool, err := pgxpool.NewWithConfig(ctx, cfg)
+	require.NoError(b, err, "connect nmdata store")
+	b.Cleanup(pool.Close)
+	nmStore := nmDataStore(b, &networkmap_pgsql.PgStore{Pool: pool})
+
+	for _, accountID := range benchAccountIDs(b, ctx, statsConn) {
+		b.Run(accountID, func(b *testing.B) {
+			logAccountShape(b, ctx, statsConn, accountID)
+			benchDBLoad(b, ctx, statsConn, tracer, func() error {
+				nmData, err := nmStore.GetNetworkMapData(ctx, accountID)
+				if err != nil {
+					return err
+				}
+				buildDataSyncResponses(ctx, nmData)
+				return nil
+			})
+		})
+	}
+}
+
+// buildAccountSyncResponses fans out to every peer like the controller's
+// account path: index maps once, twin conversion and network-map computation
+// per peer.
+func buildAccountSyncResponses(ctx context.Context, account *types.Account) {
+	validated := make(map[string]struct{}, len(account.Peers))
+	for peerID := range account.Peers {
+		validated[peerID] = struct{}{}
+	}
+	resourcePolicies := account.GetResourcePoliciesMap()
+	routers := account.GetResourceRoutersMap()
+	groupUsers := account.GetActiveGroupUsers()
+	settings := account.Settings
+	if settings == nil {
+		settings = &types.Settings{}
+	}
+	dnsCache := &cache.DNSConfigCache{}
+
+	for peerID, peer := range account.Peers {
+		nm := account.GetPeerNetworkMapFromComponents(
+			ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupUsers,
+		)
+		mgmtgrpc.ToSyncResponse(
+			ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, nm, equivDNSName, nil,
+			dnsCache, types.TwinAccountSettings(settings), settings.Extra, nil, 0,
+		)
+	}
+}
+
+// buildDataSyncResponses is the nmdata-path equivalent of
+// buildAccountSyncResponses.
+func buildDataSyncResponses(ctx context.Context, nmData *networkmap.NetworkMapData) {
+	validated := make(map[string]struct{}, len(nmData.Peers))
+	for peerID := range nmData.Peers {
+		validated[peerID] = struct{}{}
+	}
+	nmData.ValidatedPeers = validated
+	dnsCache := &cache.DNSConfigCache{}
+
+	for peerID, peer := range nmData.Peers {
+		components := nmData.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{})
+		nm := &types.NetworkMap{Network: components.Network}
+		if !components.IsEmpty() {
+			nm = types.CalculateNetworkMapFromComponents(ctx, components)
+		}
+		mgmtgrpc.ToSyncResponse(
+			ctx, nil, nil, nil, peer, nil, nil, nm, equivDNSName, nil,
+			dnsCache, nmData.AccountSettings, nil, nil, 0,
+		)
+	}
+}
+
+// benchDBLoad runs op b.N times and reports server-side pg_stat_database
+// deltas per op. A non-nil tracer additionally reports exact client round
+// trips per op.
+//
+// Backends flush cumulative stats at most once per second and only while
+// processing commands, so around each snapshot the load settles: sleep past
+// the flush interval, then run one extra untimed op whose command end flushes
+// everything pending. The trailing extra op lands inside the measured window,
+// hence the b.N+1 denominator for the server-side metrics.
+func benchDBLoad(b *testing.B, ctx context.Context, statsConn *pgx.Conn, tracer *queryCountTracer, op func() error) {
+	b.Helper()
+
+	require.NoError(b, op(), "warmup")
+	settleDBStats(b, op)
+
+	before, err := snapshotDBStats(ctx, statsConn)
+	require.NoError(b, err, "stats snapshot")
+	var queriesBefore int64
+	if tracer != nil {
+		queriesBefore = tracer.queries.Load()
+	}
+
+	b.ReportAllocs()
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		if err := op(); err != nil {
+			b.Fatal(err)
+		}
+	}
+	b.StopTimer()
+
+	settleDBStats(b, op)
+	after, err := snapshotDBStats(ctx, statsConn)
+	require.NoError(b, err, "stats snapshot")
+
+	ops := float64(b.N + 1)
+	if tracer != nil {
+		b.ReportMetric(float64(tracer.queries.Load()-queriesBefore)/ops, "queries/op")
+	}
+	b.ReportMetric(float64(after.xactCommit-before.xactCommit)/ops, "xact/op")
+	b.ReportMetric(float64(after.tupReturned-before.tupReturned)/ops, "tup_returned/op")
+	b.ReportMetric(float64(after.tupFetched-before.tupFetched)/ops, "tup_fetched/op")
+	b.ReportMetric(float64(after.blksRead-before.blksRead)/ops, "blks_read/op")
+	b.ReportMetric(float64(after.blksHit-before.blksHit)/ops, "blks_hit/op")
+}
+
+func settleDBStats(b *testing.B, op func() error) {
+	b.Helper()
+	time.Sleep(1100 * time.Millisecond)
+	require.NoError(b, op(), "stats flush op")
+	time.Sleep(100 * time.Millisecond)
+}
+
+func benchAccountIDs(b *testing.B, ctx context.Context, conn *pgx.Conn) []string {
+	b.Helper()
+
+	if ids := strings.TrimSpace(os.Getenv("NETMAP_ACCOUNTS")); ids != "" {
+		var out []string
+		for _, id := range strings.Split(ids, ",") {
+			if id = strings.TrimSpace(id); id != "" {
+				out = append(out, id)
+			}
+		}
+		return out
+	}
+
+	rows, err := conn.Query(ctx,
+		"select account_id from peers group by account_id order by count(*) desc, account_id limit 10")
+	require.NoError(b, err, "list benchmark accounts")
+	ids, err := pgx.CollectRows(rows, pgx.RowTo[string])
+	require.NoError(b, err, "collect benchmark accounts")
+	require.NotEmpty(b, ids, "no accounts found")
+	return ids
+}
+
+func logAccountShape(b *testing.B, ctx context.Context, conn *pgx.Conn, accountID string) {
+	b.Helper()
+
+	var peers, groups, users, policies, routes, resources, nsGroups int
+	err := conn.QueryRow(ctx, `select
+		(select count(*) from peers where account_id=$1),
+		(select count(*) from groups where account_id=$1),
+		(select count(*) from users where account_id=$1),
+		(select count(*) from policies where account_id=$1),
+		(select count(*) from routes where account_id=$1),
+		(select count(*) from network_resources where account_id=$1),
+		(select count(*) from name_server_groups where account_id=$1)`, accountID).
+		Scan(&peers, &groups, &users, &policies, &routes, &resources, &nsGroups)
+	require.NoError(b, err, "account shape")
+	b.Logf("account=%s peers=%d groups=%d users=%d policies=%d routes=%d resources=%d nsgroups=%d",
+		accountID, peers, groups, users, policies, routes, resources, nsGroups)
+}
+
+type dbStats struct {
+	xactCommit  int64
+	tupReturned int64
+	tupFetched  int64
+	blksRead    int64
+	blksHit     int64
+}
+
+func snapshotDBStats(ctx context.Context, conn *pgx.Conn) (dbStats, error) {
+	var s dbStats
+	err := conn.QueryRow(ctx, `select xact_commit, tup_returned, tup_fetched, blks_read, blks_hit
+		from pg_stat_database where datname = current_database()`).
+		Scan(&s.xactCommit, &s.tupReturned, &s.tupFetched, &s.blksRead, &s.blksHit)
+	return s, err
+}
+
+type queryCountTracer struct {
+	queries atomic.Int64
+}
+
+func (t *queryCountTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context {
+	t.queries.Add(1)
+	return ctx
+}
+
+func (t *queryCountTracer) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {}
diff --git a/shared/management/types/component_types.go b/management/server/types/legacynmap/component_types.go
similarity index 99%
rename from shared/management/types/component_types.go
rename to management/server/types/legacynmap/component_types.go
index 41ed758dd..a584b59af 100644
--- a/shared/management/types/component_types.go
+++ b/management/server/types/legacynmap/component_types.go
@@ -1,4 +1,4 @@
-package types
+package legacynmap
 
 import (
 	"net/netip"
diff --git a/management/server/types/legacynmap/converters.go b/management/server/types/legacynmap/converters.go
new file mode 100644
index 000000000..34e709413
--- /dev/null
+++ b/management/server/types/legacynmap/converters.go
@@ -0,0 +1,127 @@
+package legacynmap
+
+import (
+	nbdns "github.com/netbirdio/netbird/dns"
+	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/route"
+)
+
+// NetworkMap is main's shape. It is copied rather than aliased because this
+// branch's NetworkMap dropped ForceRoutingPeerDNSResolution, which main threads
+// into PeerConfig.RoutingPeerDnsResolutionEnabled.
+type NetworkMap struct {
+	Peers               []*ComponentPeer
+	Network             *Network
+	Routes              []*route.Route
+	DNSConfig           nbdns.Config
+	OfflinePeers        []*ComponentPeer
+	FirewallRules       []*FirewallRule
+	RoutesFirewallRules []*RouteFirewallRule
+	ForwardingRules     []*ForwardingRule
+	AuthorizedUsers     map[string]map[string]struct{}
+	EnableSSH           bool
+	// ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS
+	// resolution regardless of the account-global setting, for reverse-proxy
+	// domain targets.
+	ForceRoutingPeerDNSResolution bool
+}
+
+// The ToComponent converters below are main's methods, re-expressed as free
+// functions because their receivers live in packages this one cannot extend.
+// Bodies are otherwise unchanged.
+
+func peerToComponent(p *nbpeer.Peer) *ComponentPeer {
+	if p == nil {
+		return nil
+	}
+	cp := &ComponentPeer{
+		ID:                     p.ID,
+		Key:                    p.Key,
+		IP:                     p.IP,
+		IPv6:                   p.IPv6,
+		DNSLabel:               p.DNSLabel,
+		SSHKey:                 p.SSHKey,
+		SSHEnabled:             p.SSHEnabled,
+		ServerSSHAllowed:       p.Meta.Flags.ServerSSHAllowed,
+		AgentVersion:           p.Meta.WtVersion,
+		SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
+		SupportsIPv6:           p.SupportsIPv6(),
+		LoginExpirationEnabled: p.LoginExpirationEnabled,
+		AddedWithSSOLogin:      p.AddedWithSSOLogin(),
+		ProxyEmbedded:          p.ProxyMeta.Embedded,
+	}
+	if p.LastLogin != nil {
+		cp.LastLogin = *p.LastLogin
+	}
+	return cp
+}
+
+func groupToComponent(g *Group) *ComponentGroup {
+	if g == nil {
+		return nil
+	}
+	return &ComponentGroup{
+		ID:       g.ID,
+		PublicID: g.PublicID,
+		Name:     g.Name,
+		Peers:    g.Peers,
+	}
+}
+
+func groupsToComponent(groups map[string]*Group) map[string]*ComponentGroup {
+	if groups == nil {
+		return nil
+	}
+	out := make(map[string]*ComponentGroup, len(groups))
+	for id, g := range groups {
+		out[id] = groupToComponent(g)
+	}
+	return out
+}
+
+func routerToComponent(n *routerTypes.NetworkRouter) *ComponentRouter {
+	if n == nil {
+		return nil
+	}
+	return &ComponentRouter{
+		NetworkID:  n.NetworkID,
+		PublicID:   n.PublicID,
+		Peer:       n.Peer,
+		PeerGroups: n.PeerGroups,
+		Masquerade: n.Masquerade,
+		Metric:     n.Metric,
+		Enabled:    n.Enabled,
+	}
+}
+
+func routersToComponentMap(routers map[string]*routerTypes.NetworkRouter) map[string]*ComponentRouter {
+	if routers == nil {
+		return nil
+	}
+	out := make(map[string]*ComponentRouter, len(routers))
+	for id, r := range routers {
+		out[id] = routerToComponent(r)
+	}
+	return out
+}
+
+func resourceToComponent(n *resourceTypes.NetworkResource) *ComponentResource {
+	if n == nil {
+		return nil
+	}
+	return &ComponentResource{
+		ID:          n.ID,
+		PublicID:    n.PublicID,
+		NetworkID:   n.NetworkID,
+		AccountID:   n.AccountID,
+		Name:        n.Name,
+		Description: n.Description,
+		Type:        ComponentResourceType(n.Type),
+		Address:     n.Address,
+		Domain:      n.Domain,
+		Prefix:      n.Prefix,
+		Enabled:     n.Enabled,
+	}
+}
diff --git a/management/server/types/legacynmap/copied_funcs.go b/management/server/types/legacynmap/copied_funcs.go
new file mode 100644
index 000000000..4477967f5
--- /dev/null
+++ b/management/server/types/legacynmap/copied_funcs.go
@@ -0,0 +1,282 @@
+package legacynmap
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/miekg/dns"
+	log "github.com/sirupsen/logrus"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
+	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+	nbroute "github.com/netbirdio/netbird/route"
+)
+
+func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule {
+	rulesExists := make(map[string]struct{})
+	rules := make([]*RouteFirewallRule, 0)
+
+	v4Sources, v6Sources := splitPeerSourcesByFamily(groupPeers)
+
+	isV6Route := route.Network.Addr().Is6()
+
+	// Skip v6 destination routes entirely for peers without IPv6 support
+	if isV6Route && !includeIPv6 {
+		return rules
+	}
+
+	// Pick sources matching the destination family
+	sourceRanges := v4Sources
+	if isV6Route {
+		sourceRanges = v6Sources
+	}
+
+	baseRule := RouteFirewallRule{
+		PolicyID:     rule.PolicyID,
+		RouteID:      route.ID,
+		SourceRanges: sourceRanges,
+		Action:       string(rule.Action),
+		Destination:  route.Network.String(),
+		Protocol:     string(rule.Protocol),
+		Domains:      route.Domains,
+		IsDynamic:    route.IsDynamic(),
+	}
+
+	if len(rule.Ports) == 0 {
+		rules = append(rules, generateRulesWithPortRanges(baseRule, rule, rulesExists)...)
+	} else {
+		rules = append(rules, generateRulesWithPorts(ctx, baseRule, rule, rulesExists)...)
+	}
+
+	// Generate v6 counterpart for dynamic routes and 0.0.0.0/0 exit node routes.
+	isDefaultV4 := !isV6Route && route.Network.Bits() == 0
+	if includeIPv6 && (route.IsDynamic() || isDefaultV4) && len(v6Sources) > 0 {
+		v6Rule := baseRule
+		v6Rule.SourceRanges = v6Sources
+		if isDefaultV4 {
+			v6Rule.Destination = "::/0"
+			v6Rule.RouteID = route.ID + "-v6-default"
+		}
+		if len(rule.Ports) == 0 {
+			rules = append(rules, generateRulesWithPortRanges(v6Rule, rule, rulesExists)...)
+		} else {
+			rules = append(rules, generateRulesWithPorts(ctx, v6Rule, rule, rulesExists)...)
+		}
+	}
+
+	return rules
+}
+
+func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone {
+	var customZones []nbdns.CustomZone
+
+	if len(peerGroups) == 0 {
+		return customZones
+	}
+
+	for _, zone := range accountZones {
+		if !zone.Enabled || len(zone.Records) == 0 {
+			continue
+		}
+
+		hasAccess := false
+		for _, distGroupID := range zone.DistributionGroups {
+			if _, found := peerGroups[distGroupID]; found {
+				hasAccess = true
+				break
+			}
+		}
+
+		if !hasAccess {
+			continue
+		}
+
+		simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records))
+		for _, record := range zone.Records {
+			var recordType int
+			rData := record.Content
+
+			switch record.Type {
+			case records.RecordTypeA:
+				recordType = int(dns.TypeA)
+			case records.RecordTypeAAAA:
+				recordType = int(dns.TypeAAAA)
+			case records.RecordTypeCNAME:
+				recordType = int(dns.TypeCNAME)
+				rData = dns.Fqdn(record.Content)
+			default:
+				log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID)
+				continue
+			}
+
+			simpleRecords = append(simpleRecords, nbdns.SimpleRecord{
+				Name:  dns.Fqdn(record.Name),
+				Type:  recordType,
+				Class: nbdns.DefaultClass,
+				TTL:   record.TTL,
+				RData: rData,
+			})
+		}
+
+		customZones = append(customZones, nbdns.CustomZone{
+			Domain:               dns.Fqdn(zone.Domain),
+			Records:              simpleRecords,
+			SearchDomainDisabled: !zone.EnableSearchDomain,
+			NonAuthoritative:     true,
+		})
+	}
+
+	return customZones
+}
+
+func getAllowedUserIDs(a *Account) map[string]struct{} {
+	users := make(map[string]struct{})
+	for _, nbUser := range a.Users {
+		if !nbUser.IsBlocked() && !nbUser.IsServiceUser {
+			users[nbUser.Id] = struct{}{}
+		}
+	}
+	return users
+}
+
+func getUniquePeerIDsFromGroupsIDs(a *Account, ctx context.Context, groups []string) []string {
+	peerIDs := make(map[string]struct{}, len(groups)) // we expect at least one peer per group as initial capacity
+	for _, groupID := range groups {
+		group := a.GetGroup(groupID)
+		if group == nil {
+			log.WithContext(ctx).Warnf("group %s doesn't exist under account %s, will continue map generation without it", groupID, a.Id)
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			return group.Peers
+		}
+
+		for _, peerID := range group.Peers {
+			peerIDs[peerID] = struct{}{}
+		}
+	}
+
+	ids := make([]string, 0, len(peerIDs))
+	for peerID := range peerIDs {
+		ids = append(ids, peerID)
+	}
+
+	return ids
+}
+
+func forcesRoutingPeerDNSResolution(a *Account, peerID string, routers map[string]map[string]*routerTypes.NetworkRouter) bool {
+	targeted := proxyTargetedDomainResourceIDs(a)
+	if len(targeted) == 0 {
+		return false
+	}
+
+	for _, resource := range a.NetworkResources {
+		if resource == nil || !resource.Enabled || resource.Type != resourceTypes.Domain {
+			continue
+		}
+		if _, ok := targeted[resource.ID]; !ok {
+			continue
+		}
+		if _, isRouter := routers[resource.NetworkID][peerID]; isRouter {
+			return true
+		}
+	}
+
+	return false
+}
+
+func proxyTargetedDomainResourceIDs(a *Account) map[string]struct{} {
+	ids := make(map[string]struct{})
+	for _, svc := range a.Services {
+		if svc == nil || !svc.Enabled || svc.Terminated {
+			continue
+		}
+		for _, target := range svc.Targets {
+			if target == nil || !target.Enabled {
+				continue
+			}
+			if target.TargetType == service.TargetTypeDomain {
+				ids[target.TargetId] = struct{}{}
+			}
+		}
+	}
+	return ids
+}
+
+func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) {
+	v4 = make([]string, 0, len(groupPeers))
+	v6 = make([]string, 0, len(groupPeers))
+	for _, peer := range groupPeers {
+		if peer == nil {
+			continue
+		}
+		v4 = append(v4, fmt.Sprintf(AllowedIPsFormat, peer.IP))
+		if peer.IPv6.IsValid() {
+			v6 = append(v6, fmt.Sprintf(AllowedIPsV6Format, peer.IPv6))
+		}
+	}
+	return
+}
+
+func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
+	rules := make([]*RouteFirewallRule, 0)
+
+	ruleIDBase := generateRuleIDBase(rule, baseRule)
+	if len(rule.Ports) == 0 {
+		if len(rule.PortRanges) == 0 {
+			if _, ok := rulesExists[ruleIDBase]; !ok {
+				rulesExists[ruleIDBase] = struct{}{}
+				rules = append(rules, &baseRule)
+			}
+		} else {
+			for _, portRange := range rule.PortRanges {
+				ruleID := fmt.Sprintf("%s%d-%d", ruleIDBase, portRange.Start, portRange.End)
+				if _, ok := rulesExists[ruleID]; !ok {
+					rulesExists[ruleID] = struct{}{}
+					pr := baseRule
+					pr.PortRange = portRange
+					rules = append(rules, &pr)
+				}
+			}
+		}
+		return rules
+	}
+
+	return rules
+}
+
+func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
+	rules := make([]*RouteFirewallRule, 0)
+	ruleIDBase := generateRuleIDBase(rule, baseRule)
+
+	for _, port := range rule.Ports {
+		ruleID := ruleIDBase + port
+		if _, ok := rulesExists[ruleID]; ok {
+			continue
+		}
+		rulesExists[ruleID] = struct{}{}
+
+		pr := baseRule
+		p, err := strconv.ParseUint(port, 10, 16)
+		if err != nil {
+			log.WithContext(ctx).Errorf("failed to parse port %s for rule: %s", port, rule.ID)
+			continue
+		}
+
+		pr.Port = uint16(p)
+		rules = append(rules, &pr)
+	}
+
+	return rules
+}
+
+func generateRuleIDBase(rule *PolicyRule, baseRule RouteFirewallRule) string {
+	return rule.ID + strings.Join(baseRule.SourceRanges, ",") + strconv.Itoa(FirewallRuleDirectionIN) + baseRule.Protocol + baseRule.Action
+}
diff --git a/management/server/types/legacynmap/doc.go b/management/server/types/legacynmap/doc.go
new file mode 100644
index 000000000..e0b0ecd11
--- /dev/null
+++ b/management/server/types/legacynmap/doc.go
@@ -0,0 +1,16 @@
+// Package legacynmap is a frozen copy of main's Account → NetworkMapComponents
+// → NetworkMap → proto path. It exists only to measure this tree against main:
+// the proto-equivalence test runs it over a production database copy, and the
+// nmaptest golden suite runs it as a third mode so every case pins all three
+// shapes to one expectation.
+//
+// It lives in its own package so it cannot reach this tree's unexported
+// helpers — a divergence can therefore never be hidden by the two sides
+// sharing code. Nothing in production imports it.
+//
+// Types are aliased rather than copied where they are byte-identical between
+// main and this branch. Anything that drifted is copied instead; see
+// converters.go and copied_funcs.go.
+//
+// Delete this package once the nmdata refactor is validated.
+package legacynmap
diff --git a/management/server/types/legacynmap/equivalence_test.go b/management/server/types/legacynmap/equivalence_test.go
new file mode 100644
index 000000000..d12e666b8
--- /dev/null
+++ b/management/server/types/legacynmap/equivalence_test.go
@@ -0,0 +1,680 @@
+//go:build nmapequiv
+
+// Main-vs-branch equivalence check. For every peer of every account in a real
+// Postgres copy it computes the client-facing proto.NetworkMap twice:
+//
+//   - legacy path:  main's Account → NetworkMapComponents → Calculate → proto
+//     (the frozen copy in this package)
+//   - store path:   the pgsql nmdata store's NetworkMapData → components →
+//     Calculate → ToSyncResponse → proto (no Account involved)
+//   - account path: Account → toNetworkMapData twins → components → Calculate
+//     → ToSyncResponse → proto (the in-memory builder, no store queries)
+//
+// Both new paths are checked against the legacy proto.
+//
+// proto.NetworkMap is generated code identical in both trees, which is what
+// makes it the one usable comparison surface — the intermediate Go types differ
+// by design. proto.Equal would trip over repeated-field ordering, so both sides
+// are canonicalized first.
+//
+//	NETBIRD_STORE_ENGINE_POSTGRES_DSN='...' go test -tags nmapequiv \
+//	  -run TestNetworkMapProtoEquivalence -count=1 -timeout 60m \
+//	  ./management/server/types/legacynmap/
+//
+// Accounts are loaded one at a time and released between iterations, so peak
+// memory tracks the largest single account rather than the whole database.
+//
+// Env knobs: NETMAP_ACCOUNTS (comma-separated ids, skips discovery),
+// NETMAP_MAX_ACCOUNTS (0 = all), NETMAP_MAX_PEERS (0 = all). Fails at the
+// first divergence.
+package legacynmap_test
+
+import (
+	"bytes"
+	"cmp"
+	"context"
+	"os"
+	"runtime"
+	"runtime/debug"
+	"slices"
+	"sort"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+	"google.golang.org/protobuf/encoding/prototext"
+	goproto "google.golang.org/protobuf/proto"
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+	gormlogger "gorm.io/gorm/logger"
+
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
+	mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+	"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+	"github.com/netbirdio/netbird/management/server/settings"
+	"github.com/netbirdio/netbird/management/server/store"
+	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/management/server/types/legacynmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/proto"
+)
+
+const (
+	equivDNSName  = "netbird.cloud"
+	progressEvery = 5000
+)
+
+type equivStats struct {
+	accounts     int
+	peersChecked int
+}
+
+func TestNetworkMapProtoEquivalence(t *testing.T) {
+	if testing.Short() {
+		t.Skip("prod-db equivalence test, skipped in short mode")
+	}
+	dsn := equivDSN()
+	if dsn == "" {
+		t.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
+	}
+
+	ctx := context.Background()
+	// skipMigration=true: this reads a restored production copy and must not
+	// alter its schema. Flip to false only if reads fail on an older dump.
+	testStore, err := store.NewPostgresqlStore(ctx, dsn, nil, true)
+	require.NoError(t, err, "connect to postgres")
+	t.Cleanup(func() { testStore.Close(ctx) })
+
+	pgStore, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
+	require.NoError(t, err, "connect nmdata store")
+	t.Cleanup(func() { pgStore.Pool.Close() })
+	nmStore := nmDataStore(t, pgStore)
+
+	accountIDs := equivAccountIDs(t, dsn)
+	require.NotEmpty(t, accountIDs, "no accounts selected")
+
+	stats := &equivStats{accounts: len(accountIDs)}
+	maxPeers := envInt("NETMAP_MAX_PEERS", 0)
+
+	for i, accountID := range accountIDs {
+		account, err := testStore.GetAccount(ctx, accountID)
+		if err != nil {
+			t.Logf("account %s: load failed, skipping: %v", accountID, err)
+			continue
+		}
+
+		checkAccount(ctx, t, testStore, nmStore, account, maxPeers, stats)
+
+		account = nil
+		debug.FreeOSMemory()
+
+		if i%progressEvery == 0 {
+			var ms runtime.MemStats
+			runtime.ReadMemStats(&ms)
+			t.Logf("progress: accounts=%d/%d peers_checked=%d heap=%dMiB", i, len(accountIDs), stats.peersChecked, ms.HeapAlloc>>20)
+		}
+	}
+
+	t.Logf("equivalence: accounts=%d peers_checked=%d — no divergence",
+		stats.accounts, stats.peersChecked)
+}
+
+// checkAccount compares both paths for every peer of one account. Nothing is
+// retained across peers, so memory stays flat within an account.
+func checkAccount(ctx context.Context, t *testing.T, accountStore store.Store, nmStore *networkmapdb.NetworkMapDBStoreImpl, account *types.Account, maxPeers int, stats *equivStats) {
+	t.Helper()
+
+	if len(account.Peers) == 0 {
+		return
+	}
+
+	nmData, err := nmStore.GetNetworkMapData(ctx, account.Id)
+	require.NoError(t, err, "account %s: nmdata store load", account.Id)
+
+	validated := make(map[string]struct{}, len(account.Peers))
+	peerIDs := make([]string, 0, len(account.Peers))
+	for peerID := range account.Peers {
+		validated[peerID] = struct{}{}
+		peerIDs = append(peerIDs, peerID)
+	}
+	sort.Strings(peerIDs)
+	if maxPeers > 0 && len(peerIDs) > maxPeers {
+		peerIDs = peerIDs[:maxPeers]
+	}
+
+	// Production fills ValidatedPeers via the integrated-validator wrapper; here
+	// every peer counts as validated, matching the legacy side's map.
+	nmData.ValidatedPeers = validated
+
+	// Custom DNS zones are built twice from the same rows — the account side
+	// from the zones manager, the store side in SQL — so both are fed in and
+	// compared rather than dropped. The same goes for the peers zone below:
+	// each side computes it with its own helper, which is where an AAAA gate
+	// that disagrees between the two would show up.
+	accountZones, err := accountStore.GetAccountZones(ctx, store.LockingStrengthNone, account.Id)
+	require.NoError(t, err, "account %s: load account zones", account.Id)
+
+	resourcePolicies := account.GetResourcePoliciesMap()
+	routers := account.GetResourceRoutersMap()
+	groupUsers := account.GetActiveGroupUsers()
+
+	// The reverse-proxy ACLs are synthesised, never persisted. Both new paths
+	// derive them inside the twin; main derived them in the controller, onto
+	// the account, before the resource-policy map. The legacy side therefore
+	// runs on its own view of the policies — a shallow copy so the account the
+	// other two paths read stays untouched and cannot double-count them.
+	legacyAccount := *account
+	if synth := legacynmap.SynthesizeProxyPolicies(account); len(synth) > 0 {
+		legacyAccount.Policies = append(slices.Clone(account.Policies), synth...)
+	}
+	legacyResourcePolicies := legacyAccount.GetResourcePoliciesMap()
+
+	settings := account.Settings
+	if settings == nil {
+		settings = &types.Settings{}
+	}
+
+	accountPeersZone := account.GetPeersCustomZone(ctx, equivDNSName)
+	storePeersZone := networkmap.PeersCustomZone(ctx, account.Id, equivDNSName, nmData.Peers, controller.IPv6AllowedPeersFromData(nmData))
+
+	for _, peerID := range peerIDs {
+		peer := account.Peers[peerID]
+		if peer == nil {
+			continue
+		}
+		dataPeer := nmData.Peers[peerID]
+		if dataPeer == nil {
+			t.Fatalf("after %d peers: account=%s peer=%s present in account store, missing in nmdata store", stats.peersChecked, account.Id, peerID)
+		}
+
+		// STORE PATH — nmdata store through the production computation, mirroring
+		// the controller's networkMapFromData.
+		components := nmData.GetPeerNetworkMapComponents(peerID, storePeersZone)
+		storeNM := &types.NetworkMap{Network: components.Network}
+		if !components.IsEmpty() {
+			storeNM = types.CalculateNetworkMapFromComponents(ctx, components)
+		}
+		// A separate cache per side: sharing one would let the first path
+		// populate entries the second then reuses, which can mask a real diff.
+		storeProto := mgmtgrpc.ToSyncResponse(
+			ctx, nil, nil, nil, dataPeer, nil, nil, storeNM, equivDNSName, nil,
+			&cache.DNSConfigCache{}, nmData.AccountSettings, settings.Extra, nil, 0,
+		).NetworkMap
+
+		// ACCOUNT PATH — Account → toNetworkMapData twins → components.
+		acctNM := account.GetPeerNetworkMapFromComponents(
+			ctx, peerID, accountPeersZone, accountZones, validated, resourcePolicies, routers, nil, groupUsers,
+		)
+		acctProto := mgmtgrpc.ToSyncResponse(
+			ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, acctNM, equivDNSName, nil,
+			&cache.DNSConfigCache{}, types.TwinAccountSettings(settings), settings.Extra, nil, 0,
+		).NetworkMap
+
+		// LEGACY PATH — main's frozen copy.
+		legacyNM := legacynmap.GetPeerNetworkMapFromComponents(
+			&legacyAccount, ctx, peerID, accountPeersZone, accountZones, validated, legacyResourcePolicies, routers, nil, groupUsers,
+		)
+		if legacyNM == nil {
+			t.Fatalf("after %d peers: account=%s peer=%s legacy NetworkMap nil, new non-nil", stats.peersChecked, account.Id, peerID)
+		}
+		legacyProto := legacynmap.ToProtoNetworkMap(
+			ctx, peer, legacyNM, equivDNSName, settings, nil, &cache.DNSConfigCache{}, 0,
+		)
+
+		canonicalize(legacyProto)
+		canonicalize(storeProto)
+		canonicalize(acctProto)
+		stats.peersChecked++
+
+		if !goproto.Equal(legacyProto, storeProto) {
+			t.Fatalf("after %d peers: store path: %s", stats.peersChecked, describeDivergence(legacyProto, storeProto, account.Id, peerID))
+		}
+		if !goproto.Equal(legacyProto, acctProto) {
+			t.Fatalf("after %d peers: account path: %s", stats.peersChecked, describeDivergence(legacyProto, acctProto, account.Id, peerID))
+		}
+	}
+}
+
+// nmDataStore wraps a raw connection store the way production's factory does.
+// The validator marks every peer validated and the extra settings are empty:
+// checkAccount overwrites ValidatedPeers anyway, and neither reaches the
+// compared network map.
+func nmDataStore(tb testing.TB, s networkmapdb.NetworkMapDBStore) *networkmapdb.NetworkMapDBStoreImpl {
+	tb.Helper()
+
+	extraSettings := settings.NewMockManager(gomock.NewController(tb))
+	extraSettings.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil).AnyTimes()
+
+	return &networkmapdb.NetworkMapDBStoreImpl{
+		Store:                   s,
+		IntegratedPeerValidator: &validator.IntegratedValidatorImpl{},
+		ExtraSettingsManager:    extraSettings,
+	}
+}
+
+func equivDSN() string {
+	if dsn := os.Getenv("NETBIRD_STORE_ENGINE_POSTGRES_DSN"); dsn != "" {
+		return dsn
+	}
+	return os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN")
+}
+
+// equivAccountIDs lists account ids with an id-only query. store.GetAllAccounts
+// would hydrate every account in the database before the first comparison runs.
+// Sorting happens in Go so the order does not depend on database collation.
+func equivAccountIDs(t *testing.T, dsn string) []string {
+	t.Helper()
+
+	if ids := strings.TrimSpace(os.Getenv("NETMAP_ACCOUNTS")); ids != "" {
+		var out []string
+		for _, id := range strings.Split(ids, ",") {
+			if id = strings.TrimSpace(id); id != "" {
+				out = append(out, id)
+			}
+		}
+		sort.Strings(out)
+		return out
+	}
+
+	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Discard})
+	require.NoError(t, err, "open id-listing connection")
+	defer func() {
+		if sqlDB, err := db.DB(); err == nil {
+			sqlDB.Close()
+		}
+	}()
+
+	var ids []string
+	require.NoError(t, db.Model(&types.Account{}).Pluck("id", &ids).Error)
+	sort.Strings(ids)
+
+	if max := envInt("NETMAP_MAX_ACCOUNTS", 0); max > 0 && len(ids) > max {
+		ids = ids[:max]
+	}
+	return ids
+}
+
+func envInt(name string, def int) int {
+	if v := os.Getenv(name); v != "" {
+		if n, err := strconv.Atoi(v); err == nil {
+			return n
+		}
+	}
+	return def
+}
+
+// canonicalize sorts every repeated field by a stable key. Both paths iterate Go
+// maps while building these slices, so order can differ even when the content is
+// identical; without this proto.Equal reports noise.
+func canonicalize(nm *proto.NetworkMap) {
+	if nm == nil {
+		return
+	}
+	slices.SortFunc(nm.RemotePeers, cmpRemotePeer)
+	slices.SortFunc(nm.OfflinePeers, cmpRemotePeer)
+	slices.SortFunc(nm.Routes, cmpRoute)
+	slices.SortFunc(nm.FirewallRules, cmpFirewallRule)
+	slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule)
+	slices.SortFunc(nm.ForwardingRules, cmpForwardingRule)
+
+	for _, r := range nm.FirewallRules {
+		slices.SortFunc(r.SourcePrefixes, bytes.Compare)
+	}
+	for _, r := range nm.RoutesFirewallRules {
+		slices.Sort(r.SourceRanges)
+	}
+	canonicalizeDNSConfig(nm.DNSConfig)
+	canonicalizeSSHAuth(nm.SshAuth)
+}
+
+func canonicalizeDNSConfig(d *proto.DNSConfig) {
+	if d == nil {
+		return
+	}
+	for _, g := range d.NameServerGroups {
+		if g == nil {
+			continue
+		}
+		slices.Sort(g.Domains)
+		slices.SortFunc(g.NameServers, func(a, b *proto.NameServer) int {
+			if a == nil || b == nil {
+				return boolCmp(a == nil, b == nil)
+			}
+			if c := cmp.Compare(a.IP, b.IP); c != 0 {
+				return c
+			}
+			if c := cmp.Compare(a.Port, b.Port); c != 0 {
+				return c
+			}
+			return cmp.Compare(a.NSType, b.NSType)
+		})
+	}
+	slices.SortFunc(d.NameServerGroups, func(a, b *proto.NameServerGroup) int {
+		return cmp.Compare(nsgKey(a), nsgKey(b))
+	})
+	for _, z := range d.CustomZones {
+		if z == nil {
+			continue
+		}
+		slices.SortFunc(z.Records, cmpSimpleRecord)
+	}
+	slices.SortFunc(d.CustomZones, func(a, b *proto.CustomZone) int {
+		if a == nil || b == nil {
+			return boolCmp(a == nil, b == nil)
+		}
+		return cmp.Compare(a.Domain, b.Domain)
+	})
+}
+
+// canonicalizeSSHAuth sorts AuthorizedUsers and re-keys MachineUsers.Indexes
+// against the new ordering, preserving which machine user maps to which hashes.
+func canonicalizeSSHAuth(s *proto.SSHAuth) {
+	if s == nil || len(s.AuthorizedUsers) == 0 {
+		return
+	}
+	type hashed struct {
+		bytes []byte
+		old   uint32
+	}
+	entries := make([]hashed, len(s.AuthorizedUsers))
+	for i, b := range s.AuthorizedUsers {
+		entries[i] = hashed{bytes: b, old: uint32(i)}
+	}
+	slices.SortFunc(entries, func(a, b hashed) int { return bytes.Compare(a.bytes, b.bytes) })
+
+	remap := make(map[uint32]uint32, len(entries))
+	sorted := make([][]byte, len(entries))
+	for newIdx, e := range entries {
+		remap[e.old] = uint32(newIdx)
+		sorted[newIdx] = e.bytes
+	}
+	s.AuthorizedUsers = sorted
+
+	for _, mu := range s.MachineUsers {
+		if mu == nil {
+			continue
+		}
+		for i, oldIdx := range mu.Indexes {
+			if newIdx, ok := remap[oldIdx]; ok {
+				mu.Indexes[i] = newIdx
+			}
+		}
+		slices.Sort(mu.Indexes)
+	}
+}
+
+func boolCmp(a, b bool) int {
+	if a == b {
+		return 0
+	}
+	if a {
+		return 1
+	}
+	return -1
+}
+
+func nsgKey(g *proto.NameServerGroup) string {
+	if g == nil {
+		return ""
+	}
+	var parts []string
+	for _, ns := range g.NameServers {
+		if ns == nil {
+			continue
+		}
+		parts = append(parts, ns.IP+":"+strconv.FormatInt(ns.Port, 10)+":"+strconv.FormatInt(ns.NSType, 10))
+	}
+	slices.Sort(parts)
+	key := strings.Join(parts, ",")
+	domains := append([]string(nil), g.Domains...)
+	slices.Sort(domains)
+	key += "|" + strings.Join(domains, "|")
+	if g.Primary {
+		key += "|P"
+	}
+	if g.SearchDomainsEnabled {
+		key += "|S"
+	}
+	return key
+}
+
+func cmpSimpleRecord(a, b *proto.SimpleRecord) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(a.Name, b.Name); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Type, b.Type); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Class, b.Class); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.RData, b.RData); c != 0 {
+		return c
+	}
+	return cmp.Compare(a.TTL, b.TTL)
+}
+
+func cmpRemotePeer(a, b *proto.RemotePeerConfig) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	return cmp.Compare(a.WgPubKey, b.WgPubKey)
+}
+
+func cmpRoute(a, b *proto.Route) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(a.ID, b.ID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.NetID, b.NetID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Network, b.Network); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Peer, b.Peer); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Metric, b.Metric); c != 0 {
+		return c
+	}
+	return slices.Compare(a.Domains, b.Domains)
+}
+
+func cmpFirewallRule(a, b *proto.FirewallRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.PeerIP, b.PeerIP); c != 0 { //nolint:staticcheck
+		return c
+	}
+	if c := cmp.Compare(int32(a.Direction), int32(b.Direction)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Port, b.Port); c != 0 {
+		return c
+	}
+	return cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo))
+}
+
+func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.RouteID, b.RouteID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Destination, b.Destination); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
+		return c
+	}
+	if c := slices.Compare(a.Domains, b.Domains); c != 0 {
+		return c
+	}
+	if c := slices.Compare(a.SourceRanges, b.SourceRanges); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.CustomProtocol, b.CustomProtocol); c != 0 {
+		return c
+	}
+	return boolCmp(a.IsDynamic, b.IsDynamic)
+}
+
+func cmpForwardingRule(a, b *proto.ForwardingRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress)
+}
+
+func portInfoKey(pi *proto.PortInfo) string {
+	if pi == nil {
+		return ""
+	}
+	switch sel := pi.PortSelection.(type) {
+	case *proto.PortInfo_Port:
+		return "P" + strconv.FormatUint(uint64(sel.Port), 10)
+	case *proto.PortInfo_Range_:
+		if sel.Range == nil {
+			return "R"
+		}
+		return "R" + strconv.FormatUint(uint64(sel.Range.Start), 10) + "-" + strconv.FormatUint(uint64(sel.Range.End), 10)
+	}
+	return ""
+}
+
+// describeDivergence names the first differing field so a failure is actionable
+// without re-running against the database.
+func describeDivergence(legacy, updated *proto.NetworkMap, accountID, peerID string) string {
+	prefix := "account=" + accountID + " peer=" + peerID
+
+	lens := []struct {
+		field string
+		a, b  int
+		diff  func() string
+	}{
+		{"RemotePeers", len(legacy.RemotePeers), len(updated.RemotePeers), func() string { return diffLists(legacy.RemotePeers, updated.RemotePeers) }},
+		{"OfflinePeers", len(legacy.OfflinePeers), len(updated.OfflinePeers), func() string { return diffLists(legacy.OfflinePeers, updated.OfflinePeers) }},
+		{"Routes", len(legacy.Routes), len(updated.Routes), func() string { return diffLists(legacy.Routes, updated.Routes) }},
+		{"FirewallRules", len(legacy.FirewallRules), len(updated.FirewallRules), func() string { return diffLists(legacy.FirewallRules, updated.FirewallRules) }},
+		{"RoutesFirewallRules", len(legacy.RoutesFirewallRules), len(updated.RoutesFirewallRules), func() string { return diffLists(legacy.RoutesFirewallRules, updated.RoutesFirewallRules) }},
+		{"ForwardingRules", len(legacy.ForwardingRules), len(updated.ForwardingRules), func() string { return diffLists(legacy.ForwardingRules, updated.ForwardingRules) }},
+	}
+	for _, l := range lens {
+		if l.a != l.b {
+			return prefix + " field=" + l.field + " legacy_len=" + strconv.Itoa(l.a) + " new_len=" + strconv.Itoa(l.b) + l.diff()
+		}
+	}
+
+	for i := range legacy.RemotePeers {
+		if !goproto.Equal(legacy.RemotePeers[i], updated.RemotePeers[i]) {
+			return prefix + " field=RemotePeers[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.RemotePeers[i]) + " new=" + protoStr(updated.RemotePeers[i])
+		}
+	}
+	for i := range legacy.Routes {
+		if !goproto.Equal(legacy.Routes[i], updated.Routes[i]) {
+			return prefix + " field=Routes[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.Routes[i]) + " new=" + protoStr(updated.Routes[i])
+		}
+	}
+	for i := range legacy.FirewallRules {
+		if !goproto.Equal(legacy.FirewallRules[i], updated.FirewallRules[i]) {
+			return prefix + " field=FirewallRules[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.FirewallRules[i]) + " new=" + protoStr(updated.FirewallRules[i])
+		}
+	}
+	for i := range legacy.RoutesFirewallRules {
+		if !goproto.Equal(legacy.RoutesFirewallRules[i], updated.RoutesFirewallRules[i]) {
+			return prefix + " field=RoutesFirewallRules[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.RoutesFirewallRules[i]) + " new=" + protoStr(updated.RoutesFirewallRules[i])
+		}
+	}
+	if !goproto.Equal(legacy.PeerConfig, updated.PeerConfig) {
+		return prefix + " field=PeerConfig legacy=" + protoStr(legacy.PeerConfig) + " new=" + protoStr(updated.PeerConfig)
+	}
+	if !goproto.Equal(legacy.DNSConfig, updated.DNSConfig) {
+		return prefix + " field=DNSConfig legacy=" + protoStr(legacy.DNSConfig) + " new=" + protoStr(updated.DNSConfig)
+	}
+	if !goproto.Equal(legacy.SshAuth, updated.SshAuth) {
+		return prefix + " field=SshAuth legacy=" + protoStr(legacy.SshAuth) + " new=" + protoStr(updated.SshAuth)
+	}
+	if legacy.Serial != updated.Serial {
+		return prefix + " field=Serial legacy=" + strconv.FormatUint(legacy.Serial, 10) + " new=" + strconv.FormatUint(updated.Serial, 10)
+	}
+	return prefix + " (repeated fields equal element-wise — scalar/oneof mismatch)"
+}
+
+// diffLists reports the multiset difference of two repeated proto fields, so a
+// length mismatch shows which elements each side is missing.
+func diffLists[M goproto.Message](legacy, updated []M) string {
+	counts := make(map[string]int)
+	for _, m := range legacy {
+		counts[prototext.MarshalOptions{}.Format(m)]++
+	}
+	for _, m := range updated {
+		counts[prototext.MarshalOptions{}.Format(m)]--
+	}
+
+	var onlyLegacy, onlyNew []string
+	for k, c := range counts {
+		for ; c > 0; c-- {
+			onlyLegacy = append(onlyLegacy, k)
+		}
+		for ; c < 0; c++ {
+			onlyNew = append(onlyNew, k)
+		}
+	}
+	slices.Sort(onlyLegacy)
+	slices.Sort(onlyNew)
+
+	var b strings.Builder
+	for _, k := range onlyLegacy {
+		b.WriteString("\n  only_legacy: " + k)
+	}
+	for _, k := range onlyNew {
+		b.WriteString("\n  only_new: " + k)
+	}
+	return b.String()
+}
+
+func protoStr(m goproto.Message) string {
+	if m == nil {
+		return ""
+	}
+	s := prototext.Format(m)
+	const maxLen = 800
+	if len(s) > maxLen {
+		return s[:maxLen] + "...(truncated)"
+	}
+	return s
+}
diff --git a/management/server/types/legacynmap/firewall_helpers.go b/management/server/types/legacynmap/firewall_helpers.go
new file mode 100644
index 000000000..d78690f3e
--- /dev/null
+++ b/management/server/types/legacynmap/firewall_helpers.go
@@ -0,0 +1,155 @@
+package legacynmap
+
+import (
+	"strconv"
+	"strings"
+
+	v "github.com/hashicorp/go-version"
+
+	"github.com/netbirdio/netbird/version"
+)
+
+const (
+	firewallRuleMinPortRangesVer = "0.48.0"
+	firewallRuleMinNativeSSHVer  = "0.60.0"
+
+	nativeSSHPortString  = "22022"
+	nativeSSHPortNumber  = 22022
+	defaultSSHPortString = "22"
+	defaultSSHPortNumber = 22
+)
+
+type supportedFeatures struct {
+	nativeSSH  bool
+	portRanges bool
+}
+
+type LookupMap map[string]struct{}
+
+func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
+	return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
+}
+
+func portRangeIncludesSSH(portRanges []RulePortRange) bool {
+	for _, pr := range portRanges {
+		if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
+			return true
+		}
+	}
+	return false
+}
+
+func portsIncludesSSH(ports []string) bool {
+	for _, port := range ports {
+		if port == defaultSSHPortString || port == nativeSSHPortString {
+			return true
+		}
+	}
+	return false
+}
+
+// ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules.
+func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule {
+	features := peerSupportedFirewallFeatures(peer.AgentVersion)
+
+	var expanded []*FirewallRule
+
+	for _, port := range rule.Ports {
+		fr := base
+		fr.Port = port
+		expanded = append(expanded, &fr)
+	}
+
+	for _, portRange := range rule.PortRanges {
+		if len(rule.Ports) > 0 {
+			break
+		}
+		fr := base
+
+		if features.portRanges {
+			fr.PortRange = portRange
+		} else {
+			if portRange.Start != portRange.End {
+				continue
+			}
+			fr.Port = strconv.FormatUint(uint64(portRange.Start), 10)
+		}
+		expanded = append(expanded, &fr)
+	}
+
+	if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH {
+		expanded = addNativeSSHRule(base, expanded)
+	}
+
+	return expanded
+}
+
+func addNativeSSHRule(base FirewallRule, expanded []*FirewallRule) []*FirewallRule {
+	shouldAdd := false
+	for _, fr := range expanded {
+		if isPortInRule(nativeSSHPortString, 22022, fr) {
+			return expanded
+		}
+		if isPortInRule(defaultSSHPortString, 22, fr) {
+			shouldAdd = true
+		}
+	}
+	if !shouldAdd {
+		return expanded
+	}
+
+	fr := base
+	fr.Port = nativeSSHPortString
+	return append(expanded, &fr)
+}
+
+func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool {
+	return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End)
+}
+
+func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool {
+	return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP
+}
+
+func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
+	if version.IsDevelopmentVersion(peerVer) {
+		return supportedFeatures{true, true}
+	}
+
+	var features supportedFeatures
+
+	meetMinVer, err := meetsMinVersion(firewallRuleMinNativeSSHVer, peerVer)
+	features.nativeSSH = err == nil && meetMinVer
+
+	if features.nativeSSH {
+		features.portRanges = true
+	} else {
+		meetMinVer, err = meetsMinVersion(firewallRuleMinPortRangesVer, peerVer)
+		features.portRanges = err == nil && meetMinVer
+	}
+
+	return features
+}
+
+// meetsMinVersion is main's version.MeetsMinVersion, which does not exist at HEAD.
+func meetsMinVersion(minVer, peerVer string) (bool, error) {
+	peerVer = sanitizeVersion(peerVer)
+	minVer = sanitizeVersion(minVer)
+
+	peerNBVer, err := v.NewVersion(peerVer)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := v.NewConstraint(">= " + minVer)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVer), nil
+}
+
+func sanitizeVersion(version string) string {
+	parts := strings.Split(version, "-")
+	return parts[0]
+}
diff --git a/management/server/types/legacynmap/networkmap_components.go b/management/server/types/legacynmap/networkmap_components.go
new file mode 100644
index 000000000..3f71dafa5
--- /dev/null
+++ b/management/server/types/legacynmap/networkmap_components.go
@@ -0,0 +1,1032 @@
+package legacynmap
+
+import (
+	"context"
+	"maps"
+	"net/netip"
+	"slices"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/netbirdio/netbird/client/ssh/auth"
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/domain"
+)
+
+type NetworkMapComponents struct {
+	PeerID string
+
+	Network          *Network
+	AccountSettings  *AccountSettingsInfo
+	DNSSettings      *DNSSettings
+	CustomZoneDomain string
+
+	Peers               map[string]*ComponentPeer
+	Groups              map[string]*ComponentGroup
+	Policies            []*Policy
+	Routes              []*route.Route
+	NameServerGroups    []*nbdns.NameServerGroup
+	AllDNSRecords       []nbdns.SimpleRecord
+	AccountZones        []nbdns.CustomZone
+	ResourcePoliciesMap map[string][]*Policy
+	RoutersMap          map[string]map[string]*ComponentRouter
+	NetworkResources    []*ComponentResource
+
+	GroupIDToUserIDs   map[string][]string
+	AllowedUserIDs     map[string]struct{}
+	PostureFailedPeers map[string]map[string]struct{}
+
+	RouterPeers map[string]*ComponentPeer
+
+	// NetworkXIDToPublicID maps Network.ID (xid) → PublicID.
+	// Consumed by the envelope encoder to
+	// translate RoutersMap keys and NetworkResource.NetworkID references
+	// to compact uint32 ids. Legacy Calculate() doesn't consult it.
+	NetworkXIDToPublicID map[string]string
+
+	// PostureCheckXIDToPublicID maps posture.Checks.ID (xid) → PublicID.
+	// Same role as NetworkXIDToPublicID, used for PostureFailedPeers keys and
+	// policy SourcePostureChecks references.
+	PostureCheckXIDToPublicID map[string]string
+	routesByPeerOnce          sync.Once
+	routesByPeerIdx           map[string][]routeIndexEntry
+
+	// true when returning an empty-like map (returned instead of nil)
+	empty bool
+
+	// ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS
+	// resolution regardless of the account-global setting, for reverse-proxy
+	// domain targets.
+	ForceRoutingPeerDNSResolution bool
+}
+
+type routeIndexEntry struct {
+	route    *route.Route
+	viaGroup bool
+}
+
+type AccountSettingsInfo struct {
+	PeerLoginExpirationEnabled      bool
+	PeerLoginExpiration             time.Duration
+	PeerInactivityExpirationEnabled bool
+	PeerInactivityExpiration        time.Duration
+}
+
+func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents {
+	nm.empty = true
+	return nm
+}
+
+func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer {
+	return c.Peers[peerID]
+}
+
+func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer {
+	return c.RouterPeers[peerID]
+}
+
+func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup {
+	return c.Groups[groupID]
+}
+
+func (c *NetworkMapComponents) IsPeerInGroup(peerID, groupID string) bool {
+	group := c.GetGroupInfo(groupID)
+	if group == nil {
+		return false
+	}
+
+	return slices.Contains(group.Peers, peerID)
+}
+
+func (c *NetworkMapComponents) GetPeerGroups(peerID string) map[string]struct{} {
+	groups := make(map[string]struct{})
+	for groupID, group := range c.Groups {
+		if slices.Contains(group.Peers, peerID) {
+			groups[groupID] = struct{}{}
+		}
+	}
+	return groups
+}
+
+func (c *NetworkMapComponents) ValidatePostureChecksOnPeer(peerID string, postureCheckIDs []string) bool {
+	_, exists := c.Peers[peerID]
+	if !exists {
+		return false
+	}
+	if len(postureCheckIDs) == 0 {
+		return true
+	}
+	for _, checkID := range postureCheckIDs {
+		if failedPeers, exists := c.PostureFailedPeers[checkID]; exists {
+			if _, failed := failedPeers[peerID]; failed {
+				return false
+			}
+		}
+	}
+	return true
+}
+
+func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap {
+	return components.Calculate(ctx)
+}
+
+func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
+	targetPeerID := c.PeerID
+
+	peerGroups := c.GetPeerGroups(targetPeerID)
+
+	aclPeers, firewallRules, authorizedUsers, sshEnabled := c.getPeerConnectionResources(targetPeerID)
+
+	peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers)
+
+	includeIPv6 := false
+	if p := c.Peers[targetPeerID]; p != nil {
+		includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid()
+	}
+	routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6)
+	routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6)
+
+	isRouter, networkResourcesRoutes, sourcePeers := c.getNetworkResourcesRoutesToSync(targetPeerID)
+	var networkResourcesFirewallRules []*RouteFirewallRule
+	if isRouter {
+		networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes, includeIPv6)
+	}
+
+	peersToConnectIncludingRouters := c.addNetworksRoutingPeers(
+		networkResourcesRoutes,
+		targetPeerID,
+		peersToConnect,
+		expiredPeers,
+		isRouter,
+		sourcePeers,
+	)
+
+	dnsManagementStatus := c.getPeerDNSManagementStatusFromGroups(peerGroups)
+	dnsUpdate := nbdns.Config{
+		ServiceEnable: dnsManagementStatus,
+	}
+
+	if dnsManagementStatus {
+		var customZones []nbdns.CustomZone
+
+		if c.CustomZoneDomain != "" && len(c.AllDNSRecords) > 0 {
+			customZones = append(customZones, nbdns.CustomZone{
+				Domain:  c.CustomZoneDomain,
+				Records: c.AllDNSRecords,
+			})
+		}
+
+		customZones = append(customZones, c.AccountZones...)
+
+		dnsUpdate.CustomZones = customZones
+		dnsUpdate.NameServerGroups = c.getPeerNSGroupsFromGroups(targetPeerID, peerGroups)
+	}
+
+	return &NetworkMap{
+		Peers:               peersToConnectIncludingRouters,
+		Network:             c.Network.Copy(),
+		Routes:              append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...),
+		DNSConfig:           dnsUpdate,
+		OfflinePeers:        expiredPeers,
+		FirewallRules:       firewallRules,
+		RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...),
+		AuthorizedUsers:     authorizedUsers,
+		EnableSSH:           sshEnabled,
+
+		ForceRoutingPeerDNSResolution: c.ForceRoutingPeerDNSResolution,
+	}
+}
+
+func (c *NetworkMapComponents) IsEmpty() bool {
+	return c.empty
+}
+
+func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) {
+	targetPeer := c.GetPeerInfo(targetPeerID)
+	if targetPeer == nil {
+		return nil, nil, nil, false
+	}
+
+	generateResources, getAccumulatedResources := c.connResourcesGenerator(targetPeer)
+	authorizedUsers := make(map[string]map[string]struct{})
+	sshEnabled := false
+
+	for _, policy := range c.Policies {
+		if !policy.Enabled {
+			continue
+		}
+
+		for _, rule := range policy.Rules {
+			if !rule.Enabled {
+				continue
+			}
+
+			var sourcePeers, destinationPeers []*ComponentPeer
+			var peerInSources, peerInDestinations bool
+
+			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+				sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID)
+			} else {
+				sourcePeers, peerInSources = c.getAllPeersFromGroups(rule.Sources, targetPeerID, policy.SourcePostureChecks)
+			}
+
+			if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
+				destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID)
+			} else {
+				destinationPeers, peerInDestinations = c.getAllPeersFromGroups(rule.Destinations, targetPeerID, nil)
+			}
+
+			if rule.Bidirectional {
+				if peerInSources {
+					generateResources(rule, destinationPeers, FirewallRuleDirectionIN)
+				}
+				if peerInDestinations {
+					generateResources(rule, sourcePeers, FirewallRuleDirectionOUT)
+				}
+			}
+
+			if peerInSources {
+				generateResources(rule, destinationPeers, FirewallRuleDirectionOUT)
+			}
+
+			if peerInDestinations {
+				generateResources(rule, sourcePeers, FirewallRuleDirectionIN)
+			}
+
+			if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
+				sshEnabled = true
+				switch {
+				case len(rule.AuthorizedGroups) > 0:
+					for groupID, localUsers := range rule.AuthorizedGroups {
+						userIDs, ok := c.GroupIDToUserIDs[groupID]
+						if !ok {
+							continue
+						}
+
+						if len(localUsers) == 0 {
+							localUsers = []string{auth.Wildcard}
+						}
+
+						for _, localUser := range localUsers {
+							if authorizedUsers[localUser] == nil {
+								authorizedUsers[localUser] = make(map[string]struct{})
+							}
+							for _, userID := range userIDs {
+								authorizedUsers[localUser][userID] = struct{}{}
+							}
+						}
+					}
+				case rule.AuthorizedUser != "":
+					if authorizedUsers[auth.Wildcard] == nil {
+						authorizedUsers[auth.Wildcard] = make(map[string]struct{})
+					}
+					authorizedUsers[auth.Wildcard][rule.AuthorizedUser] = struct{}{}
+				default:
+					authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
+				}
+			} else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
+				sshEnabled = true
+				authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
+			}
+		}
+	}
+
+	peers, fwRules := getAccumulatedResources()
+	return peers, fwRules, authorizedUsers, sshEnabled
+}
+
+func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} {
+	if c.AllowedUserIDs != nil {
+		result := make(map[string]struct{}, len(c.AllowedUserIDs))
+		maps.Copy(result, c.AllowedUserIDs)
+		return result
+	}
+	return make(map[string]struct{})
+}
+
+func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) {
+	rulesExists := make(map[string]struct{})
+	peersExists := make(map[string]struct{})
+	rules := make([]*FirewallRule, 0)
+	peers := make([]*ComponentPeer, 0)
+
+	return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) {
+			protocol := rule.Protocol
+			if protocol == PolicyRuleProtocolNetbirdSSH {
+				protocol = PolicyRuleProtocolTCP
+			}
+
+			protocolStr := string(protocol)
+			actionStr := string(rule.Action)
+			dirStr := strconv.Itoa(direction)
+			portsJoined := strings.Join(rule.Ports, ",")
+
+			for _, peer := range groupPeers {
+				if peer == nil {
+					continue
+				}
+
+				if _, ok := peersExists[peer.ID]; !ok {
+					peers = append(peers, peer)
+					peersExists[peer.ID] = struct{}{}
+				}
+
+				peerIP := peer.IP.String()
+
+				fr := FirewallRule{
+					PolicyID:  rule.ID,
+					PeerIP:    peerIP,
+					Direction: direction,
+					Action:    actionStr,
+					Protocol:  protocolStr,
+				}
+
+				ruleID := rule.ID + peerIP + dirStr +
+					protocolStr + actionStr + portsJoined
+				if _, ok := rulesExists[ruleID]; ok {
+					continue
+				}
+				rulesExists[ruleID] = struct{}{}
+
+				if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
+					rules = append(rules, &fr)
+				} else {
+					rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...)
+				}
+
+				rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{
+					Direction:   direction,
+					DirStr:      dirStr,
+					ProtocolStr: protocolStr,
+					ActionStr:   actionStr,
+					PortsJoined: portsJoined,
+				})
+			}
+		}, func() ([]*ComponentPeer, []*FirewallRule) {
+			return peers, rules
+		}
+}
+
+func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) {
+	peerInGroups := false
+	uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups)
+	filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs))
+
+	for _, p := range uniquePeerIDs {
+		peerInfo := c.GetPeerInfo(p)
+		if peerInfo == nil {
+			continue
+		}
+
+		if _, ok := c.Peers[p]; !ok {
+			continue
+		}
+
+		if !c.ValidatePostureChecksOnPeer(p, sourcePostureChecksIDs) {
+			continue
+		}
+
+		if p == peerID {
+			peerInGroups = true
+			continue
+		}
+
+		filteredPeers = append(filteredPeers, peerInfo)
+	}
+
+	return filteredPeers, peerInGroups
+}
+
+func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) []string {
+	peerIDs := make(map[string]struct{}, len(groups))
+	for _, groupID := range groups {
+		group := c.GetGroupInfo(groupID)
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			return group.Peers
+		}
+
+		for _, peerID := range group.Peers {
+			peerIDs[peerID] = struct{}{}
+		}
+	}
+
+	ids := make([]string, 0, len(peerIDs))
+	for peerID := range peerIDs {
+		ids = append(ids, peerID)
+	}
+
+	return ids
+}
+
+func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) {
+	if resource.ID == peerID {
+		return []*ComponentPeer{}, true
+	}
+
+	peerInfo := c.GetPeerInfo(resource.ID)
+	if peerInfo == nil {
+		return []*ComponentPeer{}, false
+	}
+
+	return []*ComponentPeer{peerInfo}, false
+}
+
+func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) {
+	peersToConnect := make([]*ComponentPeer, 0, len(aclPeers))
+	var expiredPeers []*ComponentPeer
+
+	for _, p := range aclPeers {
+		expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration)
+		if c.AccountSettings.PeerLoginExpirationEnabled && expired {
+			expiredPeers = append(expiredPeers, p)
+			continue
+		}
+		peersToConnect = append(peersToConnect, p)
+	}
+
+	return peersToConnect, expiredPeers
+}
+
+func (c *NetworkMapComponents) getPeerDNSManagementStatusFromGroups(peerGroups map[string]struct{}) bool {
+	for _, groupID := range c.DNSSettings.DisabledManagementGroups {
+		if _, found := peerGroups[groupID]; found {
+			return false
+		}
+	}
+	return true
+}
+
+func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupList map[string]struct{}) []*nbdns.NameServerGroup {
+	var peerNSGroups []*nbdns.NameServerGroup
+
+	targetPeerInfo := c.GetPeerInfo(peerID)
+	if targetPeerInfo == nil {
+		return peerNSGroups
+	}
+
+	peerIPStr := targetPeerInfo.IP.String()
+
+	for _, nsGroup := range c.NameServerGroups {
+		if !nsGroup.Enabled {
+			continue
+		}
+		for _, gID := range nsGroup.Groups {
+			if _, found := groupList[gID]; found {
+				if !c.peerIsNameserver(peerIPStr, nsGroup) {
+					peerNSGroups = append(peerNSGroups, nsGroup.Copy())
+				}
+				break
+			}
+		}
+	}
+
+	return peerNSGroups
+}
+
+func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns.NameServerGroup) bool {
+	for _, ns := range nsGroup.NameServers {
+		if peerIPStr == ns.IP.String() {
+			return true
+		}
+	}
+	return false
+}
+
+// filterAndExpandRoutes drops v6 routes for non-capable peers and duplicates
+// the default v4 route (0.0.0.0/0) as ::/0 for v6-capable peers.
+// TODO: the "-v6" suffix on IDs could collide with user-supplied route IDs.
+func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Route {
+	filtered := make([]*route.Route, 0, len(routes))
+	for _, r := range routes {
+		if !includeIPv6 && r.Network.Addr().Is6() {
+			continue
+		}
+		filtered = append(filtered, r)
+
+		if includeIPv6 && r.Network.Bits() == 0 && r.Network.Addr().Is4() {
+			v6 := r.Copy()
+			v6.ID = r.ID + "-v6-default"
+			v6.NetID = r.NetID + "-v6"
+			v6.Network = netip.MustParsePrefix("::/0")
+			v6.NetworkType = route.IPv6Network
+			filtered = append(filtered, v6)
+		}
+	}
+	return filtered
+}
+
+func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route {
+	routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID)
+	peerRoutesMembership := make(LookupMap)
+	for _, r := range append(routes, peerDisabledRoutes...) {
+		peerRoutesMembership[string(r.GetHAUniqueID())] = struct{}{}
+	}
+
+	for _, peer := range aclPeers {
+		activeRoutes, _ := c.getRoutingPeerRoutes(peer.ID)
+		groupFilteredRoutes := c.filterRoutesByGroups(activeRoutes, peerGroups)
+		filteredRoutes := c.filterRoutesFromPeersOfSameHAGroup(groupFilteredRoutes, peerRoutesMembership)
+		routes = append(routes, filteredRoutes...)
+	}
+
+	return routes
+}
+
+func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*route.Route, disabledRoutes []*route.Route) {
+	peerInfo := c.GetPeerInfo(peerID)
+	if peerInfo == nil {
+		peerInfo = c.GetRouterPeerInfo(peerID)
+	}
+	if peerInfo == nil {
+		return enabledRoutes, disabledRoutes
+	}
+
+	seenRoute := make(map[route.ID]struct{})
+
+	takeRoute := func(r *route.Route) {
+		if _, ok := seenRoute[r.ID]; ok {
+			return
+		}
+		seenRoute[r.ID] = struct{}{}
+
+		r.Peer = peerInfo.Key
+
+		if r.Enabled {
+			enabledRoutes = append(enabledRoutes, r)
+			return
+		}
+		disabledRoutes = append(disabledRoutes, r)
+	}
+
+	for _, entry := range c.routesByPeer()[peerID] {
+		if entry.viaGroup {
+			newPeerRoute := entry.route.Copy()
+			newPeerRoute.PeerGroups = nil
+			newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID)
+			takeRoute(newPeerRoute)
+			continue
+		}
+		takeRoute(entry.route.Copy())
+	}
+
+	return enabledRoutes, disabledRoutes
+}
+
+func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry {
+	c.routesByPeerOnce.Do(func() {
+		idx := make(map[string][]routeIndexEntry)
+		for _, r := range c.Routes {
+			for _, groupID := range r.PeerGroups {
+				group := c.GetGroupInfo(groupID)
+				if group == nil {
+					continue
+				}
+				for _, id := range group.Peers {
+					idx[id] = append(idx[id], routeIndexEntry{route: r, viaGroup: true})
+				}
+			}
+			if r.Peer != "" {
+				idx[r.Peer] = append(idx[r.Peer], routeIndexEntry{route: r})
+			}
+		}
+		c.routesByPeerIdx = idx
+	})
+
+	return c.routesByPeerIdx
+}
+
+func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
+	var filteredRoutes []*route.Route
+	for _, r := range routes {
+		for _, groupID := range r.Groups {
+			_, found := groupListMap[groupID]
+			if found {
+				filteredRoutes = append(filteredRoutes, r)
+				break
+			}
+		}
+	}
+	return filteredRoutes
+}
+
+func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*route.Route, peerMemberships LookupMap) []*route.Route {
+	var filteredRoutes []*route.Route
+	for _, r := range routes {
+		_, found := peerMemberships[string(r.GetHAUniqueID())]
+		if !found {
+			filteredRoutes = append(filteredRoutes, r)
+		}
+	}
+	return filteredRoutes
+}
+
+func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string, includeIPv6 bool) []*RouteFirewallRule {
+	routesFirewallRules := make([]*RouteFirewallRule, 0)
+
+	enabledRoutes, _ := c.getRoutingPeerRoutes(peerID)
+	for _, r := range enabledRoutes {
+		if len(r.AccessControlGroups) == 0 {
+			defaultPermit := c.getDefaultPermit(r, includeIPv6)
+			routesFirewallRules = append(routesFirewallRules, defaultPermit...)
+			continue
+		}
+
+		distributionPeers := c.getDistributionGroupsPeers(r)
+
+		for _, accessGroup := range r.AccessControlGroups {
+			policies := c.getAllRoutePoliciesFromGroups([]string{accessGroup})
+			rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers, includeIPv6)
+			routesFirewallRules = append(routesFirewallRules, rules...)
+		}
+	}
+
+	return routesFirewallRules
+}
+
+func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool) []*RouteFirewallRule {
+	if r.Network.Addr().Is6() && !includeIPv6 {
+		return nil
+	}
+
+	sources := []string{"0.0.0.0/0"}
+	if r.Network.Addr().Is6() {
+		sources = []string{"::/0"}
+	}
+
+	rule := RouteFirewallRule{
+		SourceRanges: sources,
+		Action:       string(PolicyTrafficActionAccept),
+		Destination:  r.Network.String(),
+		Protocol:     string(PolicyRuleProtocolALL),
+		Domains:      r.Domains,
+		IsDynamic:    r.IsDynamic(),
+		RouteID:      r.ID,
+	}
+
+	rules := []*RouteFirewallRule{&rule}
+
+	isDefaultV4 := r.Network.Addr().Is4() && r.Network.Bits() == 0
+	if includeIPv6 && (r.IsDynamic() || isDefaultV4) {
+		ruleV6 := rule
+		ruleV6.SourceRanges = []string{"::/0"}
+		if isDefaultV4 {
+			ruleV6.Destination = "::/0"
+			ruleV6.RouteID = r.ID + "-v6-default"
+		}
+		rules = append(rules, &ruleV6)
+	}
+
+	return rules
+}
+
+func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[string]struct{} {
+	distPeers := make(map[string]struct{})
+	for _, id := range r.Groups {
+		group := c.GetGroupInfo(id)
+		if group == nil {
+			continue
+		}
+
+		for _, pID := range group.Peers {
+			distPeers[pID] = struct{}{}
+		}
+	}
+	return distPeers
+}
+
+func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*Policy {
+	routePolicies := make([]*Policy, 0)
+	for _, groupID := range accessControlGroups {
+		for _, policy := range c.Policies {
+			for _, rule := range policy.Rules {
+				if slices.Contains(rule.Destinations, groupID) {
+					routePolicies = append(routePolicies, policy)
+				}
+			}
+		}
+	}
+
+	return routePolicies
+}
+
+func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
+	var fwRules []*RouteFirewallRule
+	for _, policy := range policies {
+		if !policy.Enabled {
+			continue
+		}
+
+		for _, rule := range policy.Rules {
+			if !rule.Enabled {
+				continue
+			}
+
+			rulePeers := c.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers)
+			rules := GenerateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6)
+			fwRules = append(fwRules, rules...)
+		}
+	}
+	return fwRules
+}
+
+func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer {
+	distPeersWithPolicy := make(map[string]struct{})
+	for _, id := range rule.Sources {
+		group := c.GetGroupInfo(id)
+		if group == nil {
+			continue
+		}
+
+		for _, pID := range group.Peers {
+			if pID == peerID {
+				continue
+			}
+			_, distPeer := distributionPeers[pID]
+			_, valid := c.Peers[pID]
+			if distPeer && valid && c.ValidatePostureChecksOnPeer(pID, postureChecks) {
+				distPeersWithPolicy[pID] = struct{}{}
+			}
+		}
+	}
+	if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+		_, distPeer := distributionPeers[rule.SourceResource.ID]
+		_, valid := c.Peers[rule.SourceResource.ID]
+		if distPeer && valid && c.ValidatePostureChecksOnPeer(rule.SourceResource.ID, postureChecks) {
+			distPeersWithPolicy[rule.SourceResource.ID] = struct{}{}
+		}
+	}
+
+	distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy))
+	for pID := range distPeersWithPolicy {
+		peerInfo := c.GetPeerInfo(pID)
+		if peerInfo == nil {
+			continue
+		}
+		distributionGroupPeers = append(distributionGroupPeers, peerInfo)
+	}
+	return distributionGroupPeers
+}
+
+func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*route.Route, map[string]struct{}) {
+	var isRoutingPeer bool
+	var routes []*route.Route
+	allSourcePeers := make(map[string]struct{})
+
+	for _, resource := range c.NetworkResources {
+		if !resource.Enabled {
+			continue
+		}
+
+		var addSourcePeers bool
+
+		networkRoutingPeers, exists := c.RoutersMap[resource.NetworkID]
+		if exists {
+			if router, ok := networkRoutingPeers[peerID]; ok {
+				isRoutingPeer, addSourcePeers = true, true
+				routes = append(routes, c.getNetworkResourcesRoutes(resource, peerID, router)...)
+			}
+		}
+
+		newRoutes := c.processResourcePolicies(peerID, resource, networkRoutingPeers, addSourcePeers, allSourcePeers)
+		routes = append(routes, newRoutes...)
+	}
+
+	return isRoutingPeer, routes, allSourcePeers
+}
+
+func (c *NetworkMapComponents) processResourcePolicies(
+	peerID string,
+	resource *ComponentResource,
+	networkRoutingPeers map[string]*ComponentRouter,
+	addSourcePeers bool,
+	allSourcePeers map[string]struct{},
+) []*route.Route {
+	var routes []*route.Route
+
+	for _, policy := range c.ResourcePoliciesMap[resource.ID] {
+		peers := c.getResourcePolicyPeers(policy)
+		if addSourcePeers {
+			for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) {
+				allSourcePeers[pID] = struct{}{}
+			}
+			continue
+		}
+
+		if slices.Contains(peers, peerID) && c.ValidatePostureChecksOnPeer(peerID, policy.SourcePostureChecks) {
+			for peerId, router := range networkRoutingPeers {
+				routes = append(routes, c.getNetworkResourcesRoutes(resource, peerId, router)...)
+			}
+			break
+		}
+	}
+
+	return routes
+}
+
+func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string {
+	if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
+		return []string{policy.Rules[0].SourceResource.ID}
+	}
+	return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
+}
+
+func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route {
+	resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID]
+
+	var routes []*route.Route
+	if len(resourceAppliedPolicies) > 0 {
+		peerInfo := c.GetPeerInfo(peerID)
+		if peerInfo != nil {
+			routes = append(routes, c.networkResourceToRoute(resource, peerInfo, router))
+		}
+	}
+
+	return routes
+}
+
+func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route {
+	r := &route.Route{
+		ID:          route.ID(resource.ID + ":" + peer.ID),
+		AccountID:   resource.AccountID,
+		Peer:        peer.Key,
+		PeerID:      peer.ID,
+		Metric:      router.Metric,
+		Masquerade:  router.Masquerade,
+		Enabled:     resource.Enabled,
+		KeepRoute:   true,
+		NetID:       route.NetID(resource.Name),
+		Description: resource.Description,
+	}
+
+	if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet {
+		r.Network = resource.Prefix
+
+		r.NetworkType = route.IPv4Network
+		if resource.Prefix.Addr().Is6() {
+			r.NetworkType = route.IPv6Network
+		}
+	}
+
+	if resource.Type == ComponentResourceDomain {
+		domainList, err := domain.FromStringList([]string{resource.Domain})
+		if err == nil {
+			r.Domains = domainList
+			r.NetworkType = route.DomainNetwork
+			r.Network = netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32)
+		}
+	}
+
+	return r
+}
+
+func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, postureChecksIDs []string) []string {
+	var dest []string
+	for _, peerID := range inputPeers {
+		if c.ValidatePostureChecksOnPeer(peerID, postureChecksIDs) {
+			dest = append(dest, peerID)
+		}
+	}
+	return dest
+}
+
+func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route, includeIPv6 bool) []*RouteFirewallRule {
+	routesFirewallRules := make([]*RouteFirewallRule, 0)
+
+	peerInfo := c.GetPeerInfo(peerID)
+	if peerInfo == nil {
+		return routesFirewallRules
+	}
+
+	for _, r := range routes {
+		if r.Peer != peerInfo.Key {
+			continue
+		}
+
+		resourceID := string(r.GetResourceID())
+		resourcePolicies := c.ResourcePoliciesMap[resourceID]
+		distributionPeers := c.getPoliciesSourcePeers(resourcePolicies)
+
+		rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers, includeIPv6)
+		for _, rule := range rules {
+			if len(rule.SourceRanges) > 0 {
+				routesFirewallRules = append(routesFirewallRules, rule)
+			}
+		}
+	}
+
+	return routesFirewallRules
+}
+
+func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[string]struct{} {
+	sourcePeers := make(map[string]struct{})
+
+	for _, policy := range policies {
+		for _, rule := range policy.Rules {
+			for _, sourceGroup := range rule.Sources {
+				group := c.GetGroupInfo(sourceGroup)
+				if group == nil {
+					continue
+				}
+
+				for _, peer := range group.Peers {
+					sourcePeers[peer] = struct{}{}
+				}
+			}
+
+			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+				sourcePeers[rule.SourceResource.ID] = struct{}{}
+			}
+		}
+	}
+
+	return sourcePeers
+}
+
+func (c *NetworkMapComponents) addNetworksRoutingPeers(
+	networkResourcesRoutes []*route.Route,
+	peerID string,
+	peersToConnect []*ComponentPeer,
+	expiredPeers []*ComponentPeer,
+	isRouter bool,
+	sourcePeers map[string]struct{},
+) []*ComponentPeer {
+
+	networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes))
+	for _, r := range networkResourcesRoutes {
+		networkRoutesPeers[r.PeerID] = struct{}{}
+	}
+
+	delete(sourcePeers, peerID)
+	delete(networkRoutesPeers, peerID)
+
+	for _, existingPeer := range peersToConnect {
+		delete(sourcePeers, existingPeer.ID)
+		delete(networkRoutesPeers, existingPeer.ID)
+	}
+	for _, expPeer := range expiredPeers {
+		delete(sourcePeers, expPeer.ID)
+		delete(networkRoutesPeers, expPeer.ID)
+	}
+
+	missingPeers := make(map[string]struct{}, len(sourcePeers)+len(networkRoutesPeers))
+	if isRouter {
+		for p := range sourcePeers {
+			missingPeers[p] = struct{}{}
+		}
+	}
+	for p := range networkRoutesPeers {
+		missingPeers[p] = struct{}{}
+	}
+
+	for p := range missingPeers {
+		peerInfo := c.GetPeerInfo(p)
+		if peerInfo == nil {
+			peerInfo = c.GetRouterPeerInfo(p)
+		}
+		if peerInfo != nil {
+			peersToConnect = append(peersToConnect, peerInfo)
+		}
+	}
+
+	return peersToConnect
+}
+
+type FirewallRuleContext struct {
+	Direction   int
+	DirStr      string
+	ProtocolStr string
+	ActionStr   string
+	PortsJoined string
+}
+
+func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
+	if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() {
+		return rules
+	}
+
+	v6IP := peer.IPv6.String()
+	v6RuleID := rule.ID + v6IP + rc.DirStr + rc.ProtocolStr + rc.ActionStr + rc.PortsJoined
+	if _, ok := rulesExists[v6RuleID]; ok {
+		return rules
+	}
+	rulesExists[v6RuleID] = struct{}{}
+
+	v6fr := FirewallRule{
+		PolicyID:  rule.ID,
+		PeerIP:    v6IP,
+		Direction: rc.Direction,
+		Action:    rc.ActionStr,
+		Protocol:  rc.ProtocolStr,
+	}
+	if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
+		return append(rules, &v6fr)
+	}
+	return append(rules, ExpandPortsAndRanges(v6fr, rule, targetPeer)...)
+}
diff --git a/management/server/types/legacynmap/proto_legacy.go b/management/server/types/legacynmap/proto_legacy.go
new file mode 100644
index 000000000..74451b268
--- /dev/null
+++ b/management/server/types/legacynmap/proto_legacy.go
@@ -0,0 +1,220 @@
+package legacynmap
+
+import (
+	"context"
+	"fmt"
+	"net/netip"
+	"net/url"
+	"strings"
+
+	"github.com/netbirdio/netbird/client/ssh/auth"
+	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/types"
+	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/proto"
+	"github.com/netbirdio/netbird/shared/netiputil"
+)
+
+func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route {
+	protoRoutes := make([]*proto.Route, 0, len(routes))
+	for _, r := range routes {
+		protoRoutes = append(protoRoutes, ToProtocolRoute(r))
+	}
+	return protoRoutes
+}
+
+func ToProtocolRoute(route *nbroute.Route) *proto.Route {
+	return &proto.Route{
+		ID:            string(route.ID),
+		NetID:         string(route.NetID),
+		Network:       route.Network.String(),
+		Domains:       route.Domains.ToPunycodeList(),
+		NetworkType:   int64(route.NetworkType),
+		Peer:          route.Peer,
+		Metric:        int64(route.Metric),
+		Masquerade:    route.Masquerade,
+		KeepRoute:     route.KeepRoute,
+		SkipAutoApply: route.SkipAutoApply,
+	}
+}
+
+func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*ComponentPeer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig {
+	for _, rPeer := range peers {
+		allowedIPs := []string{rPeer.IP.String() + "/32"}
+		if includeIPv6 && rPeer.IPv6.IsValid() {
+			allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128")
+		}
+		dst = append(dst, &proto.RemotePeerConfig{
+			WgPubKey:     rPeer.Key,
+			AllowedIps:   allowedIPs,
+			SshConfig:    &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)},
+			Fqdn:         rPeer.FQDN(dnsName),
+			AgentVersion: rPeer.AgentVersion,
+			LazyState:    lazyStateFor(localIsProxy, rPeer),
+		})
+	}
+	return dst
+}
+
+// lazyStateFor returns the per-peer lazy override for a remote peer. Connections
+// involving an ephemeral proxy peer on either endpoint default to lazy so shared
+// proxy infrastructure is not kept permanently connected to every peer. All
+// other peers follow the account-wide flag. A future admin-facing per-peer
+// setting can return LazyStateEager here to force a peer always-active.
+func lazyStateFor(localIsProxy bool, rPeer *ComponentPeer) proto.LazyState {
+	if localIsProxy || rPeer.ProxyEmbedded {
+		return proto.LazyState_LazyStateLazy
+	}
+	return proto.LazyState_LazyStateDefault
+}
+
+func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow) *proto.JWTConfig {
+	if config == nil || config.AuthAudience == "" {
+		return nil
+	}
+
+	issuer := strings.TrimSpace(config.AuthIssuer)
+	if issuer == "" && deviceFlowConfig != nil {
+		if d := deriveIssuerFromTokenEndpoint(deviceFlowConfig.ProviderConfig.TokenEndpoint); d != "" {
+			issuer = d
+		}
+	}
+	if issuer == "" {
+		return nil
+	}
+
+	keysLocation := strings.TrimSpace(config.AuthKeysLocation)
+	if keysLocation == "" {
+		keysLocation = strings.TrimSuffix(issuer, "/") + "/.well-known/jwks.json"
+	}
+
+	audience := config.AuthAudience
+	if config.CLIAuthAudience != "" {
+		audience = config.CLIAuthAudience
+	}
+
+	audiences := []string{config.AuthAudience}
+	if config.CLIAuthAudience != "" && config.CLIAuthAudience != config.AuthAudience {
+		audiences = append(audiences, config.CLIAuthAudience)
+	}
+
+	return &proto.JWTConfig{
+		Issuer:       issuer,
+		Audience:     audience, //nolint:staticcheck
+		Audiences:    audiences,
+		KeysLocation: keysLocation,
+	}
+}
+
+func toPeerConfig(peer *nbpeer.Peer, network *Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
+	netmask, _ := network.Net.Mask.Size()
+	fqdn := peer.FQDN(dnsName)
+
+	sshConfig := &proto.SSHConfig{
+		SshEnabled: peer.SSHEnabled || enableSSH,
+	}
+
+	if sshConfig.SshEnabled {
+		sshConfig.JwtConfig = buildJWTConfig(httpConfig, deviceFlowConfig)
+	}
+
+	peerConfig := &proto.PeerConfig{
+		Address:                         fmt.Sprintf("%s/%d", peer.IP.String(), netmask),
+		SshConfig:                       sshConfig,
+		Fqdn:                            fqdn,
+		RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled || peer.ProxyMeta.Embedded || forceRoutingPeerDNS,
+		LazyConnectionEnabled:           settings.LazyConnectionEnabled,
+		AutoUpdate: &proto.AutoUpdateSettings{
+			Version:      settings.AutoUpdateVersion,
+			AlwaysUpdate: settings.AutoUpdateAlways,
+		},
+	}
+
+	if peer.SupportsIPv6() && peer.IPv6.IsValid() && network.NetV6.IP != nil {
+		ones, _ := network.NetV6.Mask.Size()
+		v6Prefix := netip.PrefixFrom(peer.IPv6.Unmap(), ones)
+		if b, err := netiputil.EncodePrefix(v6Prefix); err == nil {
+			peerConfig.AddressV6 = b
+		}
+	}
+
+	return peerConfig
+}
+
+// ToProtoNetworkMap mirrors main's ToSyncResponse, restricted to the
+// proto.NetworkMap it produces. SyncResponse-level fields (NetbirdConfig,
+// Checks, the deprecated top-level RemotePeers) are omitted — they are not part
+// of the equivalence surface. PeerConfig is included because proto.NetworkMap
+// carries it, and it is where main's ForceRoutingPeerDNSResolution surfaces.
+func ToProtoNetworkMap(
+	ctx context.Context,
+	peer *nbpeer.Peer,
+	nm *NetworkMap,
+	dnsName string,
+	settings *types.Settings,
+	httpConfig *nbconfig.HttpServerConfig,
+	dnsCache networkmap.DNSConfigCache,
+	dnsFwdPort int64,
+) *proto.NetworkMap {
+	includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
+	useSourcePrefixes := peer.SupportsSourcePrefixes()
+	localIsProxy := peer.ProxyMeta.Embedded
+
+	peerConfig := toPeerConfig(peer, nm.Network, dnsName, settings, httpConfig, nil, nm.EnableSSH, nm.ForceRoutingPeerDNSResolution)
+
+	pm := &proto.NetworkMap{
+		Serial:     nm.Network.CurrentSerial(),
+		Routes:     ToProtocolRoutes(nm.Routes),
+		DNSConfig:  networkmap.ToProtocolDNSConfig(nm.DNSConfig, dnsCache, dnsFwdPort),
+		PeerConfig: peerConfig,
+	}
+
+	remotePeers := make([]*proto.RemotePeerConfig, 0, len(nm.Peers)+len(nm.OfflinePeers))
+	remotePeers = AppendRemotePeerConfig(remotePeers, nm.Peers, dnsName, includeIPv6, localIsProxy)
+	pm.RemotePeers = remotePeers
+	pm.RemotePeersIsEmpty = len(remotePeers) == 0
+
+	pm.OfflinePeers = AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6, localIsProxy)
+
+	firewallRules := networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes)
+	pm.FirewallRules = firewallRules
+	pm.FirewallRulesIsEmpty = len(firewallRules) == 0
+
+	routesFirewallRules := networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules)
+	pm.RoutesFirewallRules = routesFirewallRules
+	pm.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0
+
+	if nm.ForwardingRules != nil {
+		forwardingRules := make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules))
+		for _, rule := range nm.ForwardingRules {
+			forwardingRules = append(forwardingRules, rule.ToProto())
+		}
+		pm.ForwardingRules = forwardingRules
+	}
+
+	if nm.AuthorizedUsers != nil {
+		hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, nm.AuthorizedUsers)
+		userIDClaim := auth.DefaultUserIDClaim
+		if httpConfig != nil && httpConfig.AuthUserIDClaim != "" {
+			userIDClaim = httpConfig.AuthUserIDClaim
+		}
+		pm.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim}
+	}
+
+	return pm
+}
+
+func deriveIssuerFromTokenEndpoint(tokenEndpoint string) string {
+	if tokenEndpoint == "" {
+		return ""
+	}
+
+	u, err := url.Parse(tokenEndpoint)
+	if err != nil {
+		return ""
+	}
+
+	return fmt.Sprintf("%s://%s/", u.Scheme, u.Host)
+}
diff --git a/management/server/types/legacynmap/proxy_policies.go b/management/server/types/legacynmap/proxy_policies.go
new file mode 100644
index 000000000..e8f8c3969
--- /dev/null
+++ b/management/server/types/legacynmap/proxy_policies.go
@@ -0,0 +1,150 @@
+package legacynmap
+
+import (
+	"fmt"
+
+	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+// SynthesizeProxyPolicies is main's Account.InjectProxyPolicies, frozen. On
+// main the network-map controller called it on the account before computing,
+// so a comparison that starts from the account has to apply it too. It returns
+// the policies instead of appending them, so the caller can measure the legacy
+// path without mutating the account the other paths share.
+func SynthesizeProxyPolicies(a *Account) []*Policy {
+	if len(a.Services) == 0 {
+		return nil
+	}
+
+	proxyPeersByCluster := a.GetProxyPeers()
+	if len(proxyPeersByCluster) == 0 {
+		return nil
+	}
+
+	var out []*Policy
+	for _, svc := range a.Services {
+		if svc == nil || !svc.Enabled {
+			continue
+		}
+
+		proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+		for _, target := range svc.Targets {
+			if target == nil || !target.Enabled {
+				continue
+			}
+			port, ok := legacyTargetPort(target)
+			if !ok {
+				continue
+			}
+			path := ""
+			if target.Path != nil {
+				path = *target.Path
+			}
+			for _, proxyPeer := range proxyPeers {
+				out = append(out, legacyProxyPolicy(svc, target, proxyPeer, port, path))
+			}
+		}
+
+		out = append(out, legacyPrivateServicePolicies(a, svc, proxyPeers)...)
+	}
+	return out
+}
+
+func legacyPrivateServicePolicies(a *Account, svc *service.Service, proxyPeers []*nbpeer.Peer) []*Policy {
+	if !svc.Private || len(svc.AccessGroups) == 0 || len(proxyPeers) == 0 {
+		return nil
+	}
+
+	sources := make([]string, 0, len(svc.AccessGroups))
+	for _, groupID := range svc.AccessGroups {
+		if _, ok := a.Groups[groupID]; ok {
+			sources = append(sources, groupID)
+		}
+	}
+	if len(sources) == 0 {
+		return nil
+	}
+
+	out := make([]*Policy, 0, len(proxyPeers))
+	for _, proxyPeer := range proxyPeers {
+		policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
+		out = append(out, &Policy{
+			ID:      policyID,
+			Name:    fmt.Sprintf("Private Access to %s", svc.Name),
+			Enabled: true,
+			Rules: []*PolicyRule{
+				{
+					ID:       policyID,
+					PolicyID: policyID,
+					Name:     fmt.Sprintf("Allow access groups to reach %s", svc.Name),
+					Enabled:  true,
+					Sources:  append([]string(nil), sources...),
+					DestinationResource: Resource{
+						ID:   proxyPeer.ID,
+						Type: ResourceTypePeer,
+					},
+					Bidirectional: false,
+					Protocol:      PolicyRuleProtocolTCP,
+					Action:        PolicyTrafficActionAccept,
+					PortRanges: []RulePortRange{
+						{Start: 80, End: 80},
+						{Start: 443, End: 443},
+					},
+				},
+			},
+		})
+	}
+	return out
+}
+
+func legacyProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy {
+	policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path)
+
+	protocol := PolicyRuleProtocolTCP
+	if svc.Mode == service.ModeUDP {
+		protocol = sharedtypes.PolicyRuleProtocolUDP
+	}
+
+	return &Policy{
+		ID:      policyID,
+		Name:    fmt.Sprintf("Proxy Access to %s", svc.Name),
+		Enabled: true,
+		Rules: []*PolicyRule{
+			{
+				ID:       policyID,
+				PolicyID: policyID,
+				Name:     fmt.Sprintf("Allow access to %s", svc.Name),
+				Enabled:  true,
+				SourceResource: Resource{
+					ID:   proxyPeer.ID,
+					Type: ResourceTypePeer,
+				},
+				DestinationResource: Resource{
+					ID:   target.TargetId,
+					Type: sharedtypes.ResourceType(target.TargetType),
+				},
+				Bidirectional: false,
+				Protocol:      protocol,
+				Action:        PolicyTrafficActionAccept,
+				PortRanges:    []RulePortRange{{Start: port, End: port}},
+			},
+		},
+	}
+}
+
+func legacyTargetPort(target *service.Target) (uint16, bool) {
+	if target.Port != 0 {
+		return target.Port, true
+	}
+
+	switch target.Protocol {
+	case "https", "tls":
+		return 443, true
+	case "http":
+		return 80, true
+	default:
+		return 0, false
+	}
+}
diff --git a/management/server/types/network.go b/management/server/types/network.go
new file mode 100644
index 000000000..72ca1af85
--- /dev/null
+++ b/management/server/types/network.go
@@ -0,0 +1,271 @@
+package types
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math/rand"
+	"net"
+	"net/netip"
+	"slices"
+	"sync"
+	"time"
+
+	"github.com/c-robinson/iplib"
+	"github.com/rs/xid"
+
+	"github.com/netbirdio/netbird/shared/management/status"
+)
+
+const (
+	// SubnetSize is a size of the subnet of the global network, e.g.  100.77.0.0/16
+	SubnetSize = 16
+	// NetSize is a global network size 100.64.0.0/10
+	NetSize = 10
+
+	// IPv6SubnetSize is the prefix length of per-account IPv6 subnets.
+	// Each account gets a /64 from its unique /48 ULA prefix.
+	IPv6SubnetSize = 64
+)
+
+type Network struct {
+	Identifier string    `json:"id"`
+	Net        net.IPNet `gorm:"serializer:json"`
+	// NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated.
+	NetV6 net.IPNet `gorm:"serializer:json"`
+	Dns   string
+	// Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added).
+	// Used to synchronize state to the client apps.
+	Serial uint64
+
+	Mu sync.Mutex `json:"-" gorm:"-"`
+}
+
+// NewNetwork creates a new Network initializing it with a Serial=0
+// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets)
+// and a random /64 subnet from fd00:4e42::/32 for IPv6.
+func NewNetwork() *Network {
+	n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
+	sub, _ := n.Subnet(SubnetSize)
+
+	s := rand.NewSource(time.Now().UnixNano())
+	r := rand.New(s)
+	intn := r.Intn(len(sub))
+
+	return &Network{
+		Identifier: xid.New().String(),
+		Net:        sub[intn].IPNet,
+		NetV6:      AllocateIPv6Subnet(r),
+		Dns:        "",
+		Serial:     0,
+	}
+}
+
+// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix.
+// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
+// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
+// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
+func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
+	ip := make(net.IP, 16)
+	ip[0] = 0xfd
+	// Bytes 1-5: 40-bit random Global ID
+	ip[1] = byte(r.Intn(256))
+	ip[2] = byte(r.Intn(256))
+	ip[3] = byte(r.Intn(256))
+	ip[4] = byte(r.Intn(256))
+	ip[5] = byte(r.Intn(256))
+	// Bytes 6-7: 16-bit random Subnet ID
+	ip[6] = byte(r.Intn(256))
+	ip[7] = byte(r.Intn(256))
+
+	return net.IPNet{
+		IP:   ip,
+		Mask: net.CIDRMask(IPv6SubnetSize, 128),
+	}
+}
+
+// IncSerial increments Serial by 1 reflecting that the network state has been changed
+func (n *Network) IncSerial() {
+	n.Mu.Lock()
+	defer n.Mu.Unlock()
+	n.Serial++
+}
+
+// CurrentSerial returns the Network.Serial of the network (latest state id)
+func (n *Network) CurrentSerial() uint64 {
+	n.Mu.Lock()
+	defer n.Mu.Unlock()
+	return n.Serial
+}
+
+func (n *Network) Copy() *Network {
+	n.Mu.Lock()
+	defer n.Mu.Unlock()
+	return &Network{
+		Identifier: n.Identifier,
+		Net:        n.Net,
+		NetV6:      n.NetV6,
+		Dns:        n.Dns,
+		Serial:     n.Serial,
+	}
+}
+
+// AllocatePeerIP picks an available IP from a netip.Prefix.
+// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
+// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
+func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
+	b := prefix.Masked().Addr().As4()
+	baseIP := binary.BigEndian.Uint32(b[:])
+	hostBits := 32 - prefix.Bits()
+	totalIPs := uint32(1 << hostBits)
+
+	taken := make(map[uint32]struct{}, len(takenIps)+1)
+	taken[baseIP] = struct{}{}            // reserve network IP
+	taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
+
+	for _, ip := range takenIps {
+		ab := ip.As4()
+		taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
+	}
+
+	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+	maxAttempts := (int(totalIPs) - len(taken)) / 100
+
+	for i := 0; i < maxAttempts; i++ {
+		offset := uint32(rng.Intn(int(totalIPs-2))) + 1
+		candidate := baseIP + offset
+		if _, exists := taken[candidate]; !exists {
+			return uint32ToIP(candidate), nil
+		}
+	}
+
+	for offset := uint32(1); offset < totalIPs-1; offset++ {
+		candidate := baseIP + offset
+		if _, exists := taken[candidate]; !exists {
+			return uint32ToIP(candidate), nil
+		}
+	}
+
+	return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String())
+}
+
+// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
+func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
+	b := prefix.Masked().Addr().As4()
+	baseIP := binary.BigEndian.Uint32(b[:])
+	hostBits := 32 - prefix.Bits()
+	totalIPs := uint32(1 << hostBits)
+
+	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+	offset := uint32(rng.Intn(int(totalIPs-2))) + 1
+
+	candidate := baseIP + offset
+	return uint32ToIP(candidate), nil
+}
+
+// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix.
+// Only the host bits (after the prefix length) are randomized.
+func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
+	ones := prefix.Bits()
+	if ones == 0 || ones > 126 || !prefix.Addr().Is6() {
+		return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String())
+	}
+
+	ip := prefix.Addr().As16()
+
+	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+
+	// Determine which byte the host bits start in
+	firstHostByte := ones / 8
+	// If the prefix doesn't end on a byte boundary, handle the partial byte
+	partialBits := ones % 8
+
+	if partialBits > 0 {
+		// Keep the network bits in the partial byte, randomize the rest
+		hostMask := byte(0xff >> partialBits)
+		ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
+		firstHostByte++
+	}
+
+	// Randomize remaining full host bytes
+	for i := firstHostByte; i < 16; i++ {
+		ip[i] = byte(rng.Intn(256))
+	}
+
+	// Avoid all-zeros and all-ones host parts by checking only host bits.
+	if isHostAllZeroOrOnes(ip[:], ones) {
+		ip = prefix.Masked().Addr().As16()
+		ip[15] |= 0x01
+	}
+
+	return netip.AddrFrom16(ip).Unmap(), nil
+}
+
+// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones.
+func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool {
+	hostStart := prefixLen / 8
+	partialBits := prefixLen % 8
+
+	hostSlice := slices.Clone(ip[hostStart:])
+	if partialBits > 0 {
+		hostSlice[0] &= 0xff >> partialBits
+	}
+
+	allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 })
+	if allZero {
+		return true
+	}
+
+	// Build the all-ones mask for host bits
+	onesMask := make([]byte, len(hostSlice))
+	for i := range onesMask {
+		onesMask[i] = 0xff
+	}
+	if partialBits > 0 {
+		onesMask[0] = 0xff >> partialBits
+	}
+
+	return slices.Equal(hostSlice, onesMask)
+}
+
+func uint32ToIP(n uint32) netip.Addr {
+	var b [4]byte
+	binary.BigEndian.PutUint32(b[:], n)
+	return netip.AddrFrom4(b)
+}
+
+// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list
+func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) {
+
+	var ips []net.IP
+	for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) {
+		if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 {
+			ips = append(ips, copyIP(ip))
+		}
+	}
+
+	// remove network address, broadcast and Fake DNS resolver address
+	lenIPs := len(ips)
+	switch {
+	case lenIPs < 2:
+		return ips, lenIPs
+	case lenIPs < 3:
+		return ips[1 : len(ips)-1], lenIPs - 2
+	default:
+		return ips[1 : len(ips)-2], lenIPs - 3
+	}
+}
+
+func copyIP(ip net.IP) net.IP {
+	dup := make(net.IP, len(ip))
+	copy(dup, ip)
+	return dup
+}
+
+func incIP(ip net.IP) {
+	for j := len(ip) - 1; j >= 0; j-- {
+		ip[j]++
+		if ip[j] > 0 {
+			break
+		}
+	}
+}
diff --git a/management/server/types/network_test.go b/management/server/types/network_test.go
new file mode 100644
index 000000000..d8a06dbbc
--- /dev/null
+++ b/management/server/types/network_test.go
@@ -0,0 +1,264 @@
+package types
+
+import (
+	"encoding/binary"
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewNetwork(t *testing.T) {
+	network := NewNetwork()
+
+	// generated net should be a subnet of a larger 100.64.0.0/10 net
+	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}}
+	assert.Equal(t, ipNet.Contains(network.Net.IP), true)
+}
+
+func TestAllocatePeerIP(t *testing.T) {
+	prefix := netip.MustParsePrefix("100.64.0.0/24")
+	var ips []netip.Addr
+	for i := 0; i < 252; i++ {
+		ip, err := AllocatePeerIP(prefix, ips)
+		if err != nil {
+			t.Fatal(err)
+		}
+		ips = append(ips, ip)
+	}
+
+	assert.Len(t, ips, 252)
+
+	uniq := make(map[string]struct{})
+	for _, ip := range ips {
+		if _, ok := uniq[ip.String()]; !ok {
+			uniq[ip.String()] = struct{}{}
+		} else {
+			t.Errorf("found duplicate IP %s", ip.String())
+		}
+	}
+}
+
+func TestAllocatePeerIPSmallSubnet(t *testing.T) {
+	// Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30)
+	prefix := netip.MustParsePrefix("10.0.0.0/27")
+	var ips []netip.Addr
+
+	// Allocate all available IPs in the /27 network
+	for i := 0; i < 30; i++ {
+		ip, err := AllocatePeerIP(prefix, ips)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Verify IP is within the correct range
+		if !prefix.Contains(ip) {
+			t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String())
+		}
+
+		ips = append(ips, ip)
+	}
+
+	assert.Len(t, ips, 30)
+
+	// Verify all IPs are unique
+	uniq := make(map[string]struct{})
+	for _, ip := range ips {
+		if _, ok := uniq[ip.String()]; !ok {
+			uniq[ip.String()] = struct{}{}
+		} else {
+			t.Errorf("found duplicate IP %s", ip.String())
+		}
+	}
+
+	// Try to allocate one more IP - should fail as network is full
+	_, err := AllocatePeerIP(prefix, ips)
+	if err == nil {
+		t.Error("expected error when network is full, but got none")
+	}
+}
+
+func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
+	testCases := []struct {
+		name           string
+		cidr           string
+		expectedUsable int
+	}{
+		{"/30 network", "192.168.1.0/30", 2},   // 4 total - 2 reserved = 2 usable
+		{"/29 network", "192.168.1.0/29", 6},   // 8 total - 2 reserved = 6 usable
+		{"/28 network", "192.168.1.0/28", 14},  // 16 total - 2 reserved = 14 usable
+		{"/27 network", "192.168.1.0/27", 30},  // 32 total - 2 reserved = 30 usable
+		{"/26 network", "192.168.1.0/26", 62},  // 64 total - 2 reserved = 62 usable
+		{"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable
+		{"/16 network", "10.0.0.0/16", 65534},  // 65536 total - 2 reserved = 65534 usable
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			prefix, err := netip.ParsePrefix(tc.cidr)
+			require.NoError(t, err)
+			prefix = prefix.Masked()
+
+			var ips []netip.Addr
+
+			// For larger networks, test only a subset to avoid long test runs
+			testCount := tc.expectedUsable
+			if testCount > 1000 {
+				testCount = 1000
+			}
+
+			// Allocate IPs and verify they're within the correct range
+			for i := 0; i < testCount; i++ {
+				ip, err := AllocatePeerIP(prefix, ips)
+				require.NoError(t, err, "failed to allocate IP %d", i)
+
+				// Verify IP is within the correct range
+				assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String())
+
+				// Verify IP is not network or broadcast address
+				networkAddr := prefix.Masked().Addr()
+				hostBits := 32 - prefix.Bits()
+				b := networkAddr.As4()
+				baseIP := binary.BigEndian.Uint32(b[:])
+				broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1)
+
+				assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String())
+				assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String())
+
+				ips = append(ips, ip)
+			}
+
+			assert.Len(t, ips, testCount)
+
+			// Verify all IPs are unique
+			uniq := make(map[string]struct{})
+			for _, ip := range ips {
+				ipStr := ip.String()
+				assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr)
+				uniq[ipStr] = struct{}{}
+			}
+		})
+	}
+}
+
+func TestGenerateIPs(t *testing.T) {
+	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
+	ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}})
+	if ipsLen != 252 {
+		t.Errorf("expected 252 ips, got %d", len(ips))
+		return
+	}
+	if ips[len(ips)-1].String() != "100.64.0.253" {
+		t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String())
+	}
+}
+
+func TestNewNetworkHasIPv6(t *testing.T) {
+	network := NewNetwork()
+
+	assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated")
+	assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6")
+	assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)")
+
+	ones, bits := network.NetV6.Mask.Size()
+	assert.Equal(t, 64, ones, "v6 subnet should be /64")
+	assert.Equal(t, 128, bits)
+}
+
+func TestAllocateIPv6SubnetUniqueness(t *testing.T) {
+	seen := make(map[string]struct{})
+	for i := 0; i < 100; i++ {
+		network := NewNetwork()
+		key := network.NetV6.IP.String()
+		_, duplicate := seen[key]
+		assert.False(t, duplicate, "duplicate v6 subnet: %s", key)
+		seen[key] = struct{}{}
+	}
+}
+
+func TestAllocateRandomPeerIPv6(t *testing.T) {
+	prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64")
+
+	ip, err := AllocateRandomPeerIPv6(prefix)
+	require.NoError(t, err)
+
+	assert.True(t, ip.Is6(), "should be IPv6")
+	assert.True(t, prefix.Contains(ip), "should be within subnet")
+	// First 8 bytes (network prefix) should match
+	b := ip.As16()
+	prefixBytes := prefix.Addr().As16()
+	assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match")
+	// Interface ID should not be all zeros
+	allZero := true
+	for _, v := range b[8:] {
+		if v != 0 {
+			allZero = false
+			break
+		}
+	}
+	assert.False(t, allZero, "interface ID should not be all zeros")
+}
+
+func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) {
+	tests := []struct {
+		name   string
+		cidr   string
+		prefix int
+	}{
+		{"standard /64", "fd00:1234:5678:abcd::/64", 64},
+		{"small /112", "fd00:1234:5678:abcd::/112", 112},
+		{"large /48", "fd00:1234::/48", 48},
+		{"non-boundary /60", "fd00:1234:5670::/60", 60},
+		{"non-boundary /52", "fd00:1230::/52", 52},
+		{"minimum /120", "fd00:1234:5678:abcd::100/120", 120},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			prefix, err := netip.ParsePrefix(tt.cidr)
+			require.NoError(t, err)
+			prefix = prefix.Masked()
+
+			assert.Equal(t, tt.prefix, prefix.Bits())
+
+			for i := 0; i < 50; i++ {
+				ip, err := AllocateRandomPeerIPv6(prefix)
+				require.NoError(t, err)
+				assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
+			}
+		})
+	}
+}
+
+func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) {
+	// For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary
+	prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112")
+
+	prefixBytes := prefix.Addr().As16()
+	for i := 0; i < 20; i++ {
+		ip, err := AllocateRandomPeerIPv6(prefix)
+		require.NoError(t, err)
+		// First 14 bytes (112 bits = 14 bytes) must match the network
+		b := ip.As16()
+		assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112")
+	}
+}
+
+func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) {
+	// For a /60, the first 7.5 bytes are network, so byte 7 is partial
+	prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60")
+
+	prefixBytes := prefix.Addr().As16()
+	for i := 0; i < 50; i++ {
+		ip, err := AllocateRandomPeerIPv6(prefix)
+		require.NoError(t, err)
+		b := ip.As16()
+		assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
+		// First 7 bytes must match exactly
+		assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60")
+		// Byte 7: top 4 bits (0xc = 1100) must be preserved
+		assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60")
+	}
+}
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index 825d51d4e..eb3e4fe3b 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -388,7 +388,7 @@ func TestComponents_NetworkSerial(t *testing.T) {
 	account.Network.Serial = 42
 	nm := componentsNetworkMap(account, "peer-0", validatedPeers)
 	require.NotNil(t, nm)
-	assert.Equal(t, uint64(42), nm.Network.Serial, "network serial should match")
+	assert.Equal(t, uint64(42), nm.Network.CurrentSerial(), "network serial should match")
 }
 
 // ──────────────────────────────────────────────────────────────────────────────
@@ -812,7 +812,7 @@ func TestComponents_AllPeersGetValidMaps(t *testing.T) {
 		}
 		nm := componentsNetworkMap(account, peerID, validatedPeers)
 		require.NotNil(t, nm, "network map should not be nil for %s", peerID)
-		assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID)
+		assert.Equal(t, account.Network.Serial, nm.Network.CurrentSerial(), "serial mismatch for %s", peerID)
 		assert.NotEmpty(t, nm.Peers, "validated peer %s should see other peers", peerID)
 	}
 }
@@ -833,7 +833,7 @@ func TestComponents_LargeScaleMapGeneration(t *testing.T) {
 				require.NotNil(t, nm, "network map should not be nil for %s", peerID)
 				assert.NotEmpty(t, nm.Peers, "peer %s should see other peers at scale", peerID)
 				assert.NotEmpty(t, nm.Routes, "peer %s should have routes at scale", peerID)
-				assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID)
+				assert.Equal(t, account.Network.Serial, nm.Network.CurrentSerial(), "serial mismatch for %s", peerID)
 			}
 		})
 	}
diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go
index 3f2288f88..f6d542609 100644
--- a/management/server/types/networkmap_components_test.go
+++ b/management/server/types/networkmap_components_test.go
@@ -18,6 +18,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func networkMapFromComponents(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) *types.NetworkMap {
@@ -49,7 +50,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str
 	return validated
 }
 
-func peerIDs(peers []*types.ComponentPeer) []string {
+func peerIDs(peers []*nmdata.Peer) []string {
 	ids := make([]string, len(peers))
 	for i, p := range peers {
 		ids[i] = p.ID
@@ -625,7 +626,7 @@ func TestNetworkMapComponents_DomainNetworkResource(t *testing.T) {
 
 	var hasDomainRoute bool
 	for _, r := range nm.Routes {
-		if r.NetworkType == route.DomainNetwork && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
+		if r.NetworkType == int(route.DomainNetwork) && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
 			hasDomainRoute = true
 		}
 	}
diff --git a/management/server/types/networkmap_wire_benchmark_test.go b/management/server/types/networkmap_wire_benchmark_test.go
index ee9839a3f..ccec054cd 100644
--- a/management/server/types/networkmap_wire_benchmark_test.go
+++ b/management/server/types/networkmap_wire_benchmark_test.go
@@ -66,7 +66,7 @@ func BenchmarkNetworkMapWireEncode(b *testing.B) {
 
 		// Pre-encode once so the size metric is identical for every run inside
 		// the same scale; the b.Loop call only re-runs encode + Marshal.
-		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 		legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap)
 		if err != nil {
 			b.Fatalf("marshal legacy networkmap: %v", err)
@@ -88,7 +88,7 @@ func BenchmarkNetworkMapWireEncode(b *testing.B) {
 			b.ReportMetric(float64(len(legacyBytes)), "bytes/msg")
 			b.ResetTimer()
 			for range b.N {
-				resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+				resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 				if _, err := goproto.Marshal(resp.NetworkMap); err != nil {
 					b.Fatal(err)
 				}
@@ -135,7 +135,7 @@ func BenchmarkNetworkMapWireSize(b *testing.B) {
 		dnsCache := &cache.DNSConfigCache{}
 		settings := &types.Settings{}
 
-		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 		legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap)
 		if err != nil {
 			b.Fatalf("marshal legacy networkmap: %v", err)
diff --git a/management/server/types/networkmap_wire_breakdown_test.go b/management/server/types/networkmap_wire_breakdown_test.go
index ac2855fa3..adf66b386 100644
--- a/management/server/types/networkmap_wire_breakdown_test.go
+++ b/management/server/types/networkmap_wire_breakdown_test.go
@@ -45,7 +45,7 @@ func TestNetworkMapWireBreakdown(t *testing.T) {
 	dnsCache := &cache.DNSConfigCache{}
 	settings := &types.Settings{}
 
-	legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+	legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 	legacyTotal := mustMarshalSize(t, legacyResp.NetworkMap)
 
 	envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
diff --git a/shared/management/types/policy.go b/management/server/types/policy.go
similarity index 58%
rename from shared/management/types/policy.go
rename to management/server/types/policy.go
index b8f605b94..0f7298d18 100644
--- a/shared/management/types/policy.go
+++ b/management/server/types/policy.go
@@ -1,32 +1,5 @@
 package types
 
-import (
-	"errors"
-	"fmt"
-	"strconv"
-	"strings"
-)
-
-const (
-	// PolicyTrafficActionAccept indicates that the traffic is accepted
-	PolicyTrafficActionAccept = PolicyTrafficActionType("accept")
-	// PolicyTrafficActionDrop indicates that the traffic is dropped
-	PolicyTrafficActionDrop = PolicyTrafficActionType("drop")
-)
-
-const (
-	// PolicyRuleProtocolALL type of traffic
-	PolicyRuleProtocolALL = PolicyRuleProtocolType("all")
-	// PolicyRuleProtocolTCP type of traffic
-	PolicyRuleProtocolTCP = PolicyRuleProtocolType("tcp")
-	// PolicyRuleProtocolUDP type of traffic
-	PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp")
-	// PolicyRuleProtocolICMP type of traffic
-	PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp")
-	// PolicyRuleProtocolNetbirdSSH type of traffic
-	PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh")
-)
-
 const (
 	// PolicyRuleFlowDirect allows traffic from source to destination
 	PolicyRuleFlowDirect = PolicyRuleDirection("direct")
@@ -184,85 +157,3 @@ func (p *Policy) SourceGroups() []string {
 
 	return groupIDs
 }
-
-func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) {
-	rule = strings.TrimSpace(strings.ToLower(rule))
-	if rule == "all" {
-		return PolicyRuleProtocolALL, RulePortRange{}, nil
-	}
-	if rule == "icmp" {
-		return PolicyRuleProtocolICMP, RulePortRange{}, nil
-	}
-
-	split := strings.Split(rule, "/")
-	if len(split) != 2 {
-		return "", RulePortRange{}, errors.New("invalid rule format: expected protocol/port or protocol/port-range")
-	}
-
-	protoStr := strings.TrimSpace(split[0])
-	portStr := strings.TrimSpace(split[1])
-
-	var protocol PolicyRuleProtocolType
-	switch protoStr {
-	case "tcp":
-		protocol = PolicyRuleProtocolTCP
-	case "udp":
-		protocol = PolicyRuleProtocolUDP
-	case "icmp":
-		return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'")
-	case "netbird-ssh":
-		return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil
-	default:
-		return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr)
-	}
-
-	portRange, err := parsePortRange(portStr)
-	if err != nil {
-		return "", RulePortRange{}, err
-	}
-
-	return protocol, portRange, nil
-}
-
-func parsePortRange(portStr string) (RulePortRange, error) {
-	if strings.Contains(portStr, "-") {
-		rangeParts := strings.Split(portStr, "-")
-		if len(rangeParts) != 2 {
-			return RulePortRange{}, fmt.Errorf("invalid port range %q", portStr)
-		}
-		start, err := parsePort(strings.TrimSpace(rangeParts[0]))
-		if err != nil {
-			return RulePortRange{}, err
-		}
-		end, err := parsePort(strings.TrimSpace(rangeParts[1]))
-		if err != nil {
-			return RulePortRange{}, err
-		}
-		if start > end {
-			return RulePortRange{}, fmt.Errorf("invalid port range: start %d > end %d", start, end)
-		}
-		return RulePortRange{Start: uint16(start), End: uint16(end)}, nil
-	}
-
-	p, err := parsePort(portStr)
-	if err != nil {
-		return RulePortRange{}, err
-	}
-
-	return RulePortRange{Start: uint16(p), End: uint16(p)}, nil
-}
-
-func parsePort(portStr string) (int, error) {
-
-	if portStr == "" {
-		return 0, errors.New("empty port")
-	}
-	p, err := strconv.Atoi(portStr)
-	if err != nil {
-		return 0, fmt.Errorf("invalid port %q: %w", portStr, err)
-	}
-	if p < 1 || p > 65535 {
-		return 0, fmt.Errorf("port out of range (1–65535): %d", p)
-	}
-	return p, nil
-}
diff --git a/management/server/types/policyrule.go b/management/server/types/policyrule.go
new file mode 100644
index 000000000..87905f005
--- /dev/null
+++ b/management/server/types/policyrule.go
@@ -0,0 +1,196 @@
+package types
+
+import (
+	"slices"
+)
+
+// PolicyUpdateOperationType operation type
+type PolicyUpdateOperationType int
+
+// PolicyRuleDirection direction of traffic
+type PolicyRuleDirection string
+
+// PolicyRule is the metadata of the policy
+type PolicyRule struct {
+	// ID of the policy rule
+	ID string `gorm:"primaryKey"`
+
+	// PolicyID is a reference to Policy that this object belongs
+	PolicyID string `json:"-" gorm:"index"`
+
+	// Name of the rule visible in the UI
+	Name string
+
+	// Description of the rule visible in the UI
+	Description string
+
+	// Enabled status of rule in the system
+	Enabled bool
+
+	// Action policy accept or drops packets
+	Action PolicyTrafficActionType
+
+	// Destinations policy destination groups
+	Destinations []string `gorm:"serializer:json"`
+
+	// DestinationResource policy destination resource that the rule is applied to
+	DestinationResource Resource `gorm:"serializer:json"`
+
+	// Sources policy source groups
+	Sources []string `gorm:"serializer:json"`
+
+	// SourceResource policy source resource that the rule is applied to
+	SourceResource Resource `gorm:"serializer:json"`
+
+	// Bidirectional define if the rule is applicable in both directions, sources, and destinations
+	Bidirectional bool
+
+	// Protocol type of the traffic
+	Protocol PolicyRuleProtocolType
+
+	// Ports or it ranges list
+	Ports []string `gorm:"serializer:json"`
+
+	// PortRanges a list of port ranges.
+	PortRanges []RulePortRange `gorm:"serializer:json"`
+
+	// AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh
+	AuthorizedGroups map[string][]string `gorm:"serializer:json"`
+
+	// AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh
+	AuthorizedUser string
+}
+
+// Copy returns a copy of a policy rule
+func (pm *PolicyRule) Copy() *PolicyRule {
+	rule := &PolicyRule{
+		ID:                  pm.ID,
+		PolicyID:            pm.PolicyID,
+		Name:                pm.Name,
+		Description:         pm.Description,
+		Enabled:             pm.Enabled,
+		Action:              pm.Action,
+		Destinations:        make([]string, len(pm.Destinations)),
+		DestinationResource: pm.DestinationResource,
+		Sources:             make([]string, len(pm.Sources)),
+		SourceResource:      pm.SourceResource,
+		Bidirectional:       pm.Bidirectional,
+		Protocol:            pm.Protocol,
+		Ports:               make([]string, len(pm.Ports)),
+		PortRanges:          make([]RulePortRange, len(pm.PortRanges)),
+		AuthorizedGroups:    make(map[string][]string, len(pm.AuthorizedGroups)),
+		AuthorizedUser:      pm.AuthorizedUser,
+	}
+	copy(rule.Destinations, pm.Destinations)
+	copy(rule.Sources, pm.Sources)
+	copy(rule.Ports, pm.Ports)
+	copy(rule.PortRanges, pm.PortRanges)
+	for k, v := range pm.AuthorizedGroups {
+		rule.AuthorizedGroups[k] = make([]string, len(v))
+		copy(rule.AuthorizedGroups[k], v)
+	}
+	return rule
+}
+
+func (pm *PolicyRule) Equal(other *PolicyRule) bool {
+	if pm == nil || other == nil {
+		return pm == other
+	}
+
+	if pm.ID != other.ID ||
+		pm.PolicyID != other.PolicyID ||
+		pm.Name != other.Name ||
+		pm.Description != other.Description ||
+		pm.Enabled != other.Enabled ||
+		pm.Action != other.Action ||
+		pm.Bidirectional != other.Bidirectional ||
+		pm.Protocol != other.Protocol ||
+		pm.SourceResource != other.SourceResource ||
+		pm.DestinationResource != other.DestinationResource ||
+		pm.AuthorizedUser != other.AuthorizedUser {
+		return false
+	}
+
+	if !stringSlicesEqualUnordered(pm.Sources, other.Sources) {
+		return false
+	}
+	if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) {
+		return false
+	}
+	if !stringSlicesEqualUnordered(pm.Ports, other.Ports) {
+		return false
+	}
+	if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) {
+		return false
+	}
+	if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) {
+		return false
+	}
+
+	return true
+}
+
+func stringSlicesEqualUnordered(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	if len(a) == 0 {
+		return true
+	}
+	sorted1 := make([]string, len(a))
+	sorted2 := make([]string, len(b))
+	copy(sorted1, a)
+	copy(sorted2, b)
+	slices.Sort(sorted1)
+	slices.Sort(sorted2)
+	return slices.Equal(sorted1, sorted2)
+}
+
+func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	if len(a) == 0 {
+		return true
+	}
+	cmp := func(x, y RulePortRange) int {
+		if x.Start != y.Start {
+			if x.Start < y.Start {
+				return -1
+			}
+			return 1
+		}
+		if x.End != y.End {
+			if x.End < y.End {
+				return -1
+			}
+			return 1
+		}
+		return 0
+	}
+	sorted1 := make([]RulePortRange, len(a))
+	sorted2 := make([]RulePortRange, len(b))
+	copy(sorted1, a)
+	copy(sorted2, b)
+	slices.SortFunc(sorted1, cmp)
+	slices.SortFunc(sorted2, cmp)
+	return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool {
+		return x.Start == y.Start && x.End == y.End
+	})
+}
+
+func authorizedGroupsEqual(a, b map[string][]string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for k, va := range a {
+		vb, ok := b[k]
+		if !ok {
+			return false
+		}
+		if !stringSlicesEqualUnordered(va, vb) {
+			return false
+		}
+	}
+	return true
+}
diff --git a/management/server/types/resource.go b/management/server/types/resource.go
new file mode 100644
index 000000000..0f065c850
--- /dev/null
+++ b/management/server/types/resource.go
@@ -0,0 +1,30 @@
+package types
+
+import (
+	"github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+type Resource struct {
+	ID   string
+	Type ResourceType
+}
+
+func (r *Resource) ToAPIResponse() *api.Resource {
+	if r.ID == "" && r.Type == "" {
+		return nil
+	}
+
+	return &api.Resource{
+		Id:   r.ID,
+		Type: api.ResourceType(r.Type),
+	}
+}
+
+func (r *Resource) FromAPIRequest(req *api.Resource) {
+	if req == nil {
+		return
+	}
+
+	r.ID = req.Id
+	r.Type = ResourceType(req.Type)
+}
diff --git a/management/server/types/user.go b/management/server/types/user.go
index dc601e15b..2e975809c 100644
--- a/management/server/types/user.go
+++ b/management/server/types/user.go
@@ -6,7 +6,7 @@ import (
 	"time"
 
 	"github.com/netbirdio/netbird/management/server/idp"
-	"github.com/netbirdio/netbird/management/server/integration_reference"
+	"github.com/netbirdio/netbird/shared/management/integration_reference"
 	"github.com/netbirdio/netbird/util/crypt"
 )
 
diff --git a/management/server/user_test.go b/management/server/user_test.go
index a2e71616a..3a2414540 100644
--- a/management/server/user_test.go
+++ b/management/server/user_test.go
@@ -33,7 +33,7 @@ import (
 	"github.com/netbirdio/netbird/idp/dex"
 	"github.com/netbirdio/netbird/management/server/activity"
 	"github.com/netbirdio/netbird/management/server/idp"
-	"github.com/netbirdio/netbird/management/server/integration_reference"
+	"github.com/netbirdio/netbird/shared/management/integration_reference"
 )
 
 const (
diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go
index d4888fee2..d91dab221 100644
--- a/shared/management/client/client_test.go
+++ b/shared/management/client/client_test.go
@@ -126,7 +126,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManger), config)
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManger), config, nil)
 	accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
 	if err != nil {
 		t.Fatal(err)
diff --git a/management/server/integration_reference/integration_reference.go b/shared/management/integration_reference/integration_reference.go
similarity index 100%
rename from management/server/integration_reference/integration_reference.go
rename to shared/management/integration_reference/integration_reference.go
diff --git a/shared/management/networkmap/decode.go b/shared/management/networkmap/decode.go
index 07a7e400e..0cd45e417 100644
--- a/shared/management/networkmap/decode.go
+++ b/shared/management/networkmap/decode.go
@@ -1,18 +1,19 @@
 package networkmap
 
 import (
+	"context"
 	"encoding/base64"
 	"fmt"
 	"net"
 	"net/netip"
+	"slices"
 	"strconv"
 	"time"
 
 	log "github.com/sirupsen/logrus"
 
-	nbdns "github.com/netbirdio/netbird/dns"
-	nbroute "github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/management/types"
 )
@@ -24,7 +25,7 @@ import (
 // ID scheme on the client side:
 //
 //	Peers              base64(wg_pub_key)          // stable across snapshots
-func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, error) {
+func DecodeEnvelope(ctx context.Context, env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, error) {
 	full := env.GetFull()
 	if full == nil {
 		return nil, fmt.Errorf("envelope has no Full payload")
@@ -35,28 +36,28 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 		Network:             decodeAccountNetwork(full.Network),
 		AccountSettings:     decodeAccountSettings(full.AccountSettings),
 		CustomZoneDomain:    full.CustomZoneDomain,
-		Peers:               make(map[string]*types.ComponentPeer, len(full.Peers)),
-		Groups:              make(map[string]*types.ComponentGroup, len(full.Groups)),
-		Policies:            make([]*types.Policy, 0, len(full.Policies)),
-		Routes:              make([]*nbroute.Route, 0, len(full.Routes)),
-		NameServerGroups:    make([]*nbdns.NameServerGroup, 0, len(full.NameserverGroups)),
+		Peers:               make(map[string]*nmdata.Peer, len(full.Peers)),
+		Groups:              make(map[string]*nmdata.Group, len(full.Groups)),
+		Policies:            make([]*nmdata.Policy, 0, len(full.Policies)),
+		Routes:              make([]*nmdata.Route, 0, len(full.Routes)),
+		NameServerGroups:    make([]*nmdata.NameServerGroup, 0, len(full.NameserverGroups)),
 		AllDNSRecords:       decodeSimpleRecords(full.AllDnsRecords),
 		AccountZones:        decodeCustomZones(full.AccountZones),
-		ResourcePoliciesMap: make(map[string][]*types.Policy),
-		RoutersMap:          make(map[string]map[string]*types.ComponentRouter),
-		NetworkResources:    make([]*types.ComponentResource, 0, len(full.NetworkResources)),
-		RouterPeers:         make(map[string]*types.ComponentPeer),
+		ResourcePoliciesMap: make(map[string][]*nmdata.Policy),
+		RoutersMap:          make(map[string]map[string]*nmdata.NetworkRouter),
+		NetworkResources:    make([]*nmdata.NetworkResource, 0, len(full.NetworkResources)),
+		RouterPeers:         make(map[string]*nmdata.Peer),
 		AllowedUserIDs:      stringSliceToSet(full.AllowedUserIds),
 		PostureFailedPeers:  make(map[string]map[string]struct{}, len(full.PostureFailedPeers)),
 		GroupIDToUserIDs:    make(map[string][]string, len(full.GroupIdToUserIds)),
 	}
 
 	if full.DnsSettings != nil {
-		c.DNSSettings = &types.DNSSettings{
+		c.DNSSettings = &nmdata.DNSSettings{
 			DisabledManagementGroups: full.DnsSettings.DisabledManagementGroupIds,
 		}
 	} else {
-		c.DNSSettings = &types.DNSSettings{}
+		c.DNSSettings = &nmdata.DNSSettings{}
 	}
 
 	// Phase 1: peers. The envelope's peers slice is index-addressed on the
@@ -98,20 +99,36 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 				log.WithField("peer idx", idx).Error("unrecognized peer idx during decoding")
 			}
 		}
-		group := &types.ComponentGroup{
-			ID:       groupID,
-			PublicID: gc.Id,
-			Peers:    peerIDs,
+
+		fromCompactResources := func() []nmdata.Resource {
+			var toret []nmdata.Resource
+
+			for _, r := range gc.Resources {
+				res := resourceFromProto(r, peerIDByIndex)
+				if res == (nmdata.Resource{}) {
+					log.WithContext(ctx).Warnf("skipping invalid resource in group compact: %s", r.String())
+					continue
+				}
+				toret = append(toret, res)
+			}
+
+			return toret
+		}
+
+		group := &nmdata.Group{
+			PublicID:  gc.Id,
+			Peers:     peerIDs,
+			Resources: fromCompactResources(),
 		}
 		if gc.IsAll {
-			group.Name = types.GroupAllName
+			group.Name = nmdata.GroupAllName
 		}
 		c.Groups[groupID] = group
 	}
 
 	// Phase 3: policies (PolicyCompact = one rule per entry; current data
 	// model is 1 rule per policy).
-	policyByID := make(map[string]*types.Policy, len(full.Policies))
+	policyByID := make(map[string]*nmdata.Policy, len(full.Policies))
 	for i, pc := range full.Policies {
 		if pc == nil {
 			return nil, fmt.Errorf("invalid envelope: policies[%d] is nil", i)
@@ -148,7 +165,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 	// Phase 7: routers_map (outer key = network seq id, inner key = peer-id
 	// reconstructed from peer_index). Synthesized network id is "net_".
 	for networkID, list := range full.RoutersMap {
-		inner := make(map[string]*types.ComponentRouter, len(list.Entries))
+		inner := make(map[string]*nmdata.NetworkRouter, len(list.Entries))
 		for _, entry := range list.Entries {
 			if !entry.PeerIndexSet {
 				continue
@@ -158,10 +175,8 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 				continue
 			}
 			peerID := peerIDByIndex[entry.PeerIndex]
-			inner[peerID] = &types.ComponentRouter{
-				NetworkID:  networkID,
+			inner[peerID] = &nmdata.NetworkRouter{
 				PublicID:   entry.Id,
-				Peer:       peerID,
 				PeerGroups: entry.PeerGroupIds,
 				Masquerade: entry.Masquerade,
 				Metric:     int(entry.Metric),
@@ -180,7 +195,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 		if len(ids.Ids) == 0 {
 			continue
 		}
-		policies := make([]*types.Policy, 0, len(ids.Ids))
+		policies := make([]*nmdata.Policy, 0, len(ids.Ids))
 		for _, id := range ids.Ids {
 			if p, ok := policyByID[id]; ok {
 				policies = append(policies, p)
@@ -193,6 +208,15 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 		}
 	}
 
+	// Phase 8: rebuild resource_policies_map
+	for _, r := range c.NetworkResources {
+		policies := policiesForNetworkResource(r.ID, c.Policies, c.Groups)
+		if len(policies) == 0 {
+			continue
+		}
+		c.ResourcePoliciesMap[r.ID] = policies
+	}
+
 	// Phase 9: group_id_to_user_ids — wire keys are seq ids, synth to strings.
 	for groupId, list := range full.GroupIdToUserIds {
 		c.GroupIDToUserIDs[groupId] = append([]string(nil), list.UserIds...)
@@ -228,17 +252,54 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 	return c, nil
 }
 
+func networkResourceGroups(resourceId string, groups map[string]*nmdata.Group) []string {
+	var toret []string
+	for _, group := range groups {
+		for _, resource := range group.Resources {
+			if resource.ID == resourceId {
+				toret = append(toret, group.PublicID)
+			}
+		}
+	}
+	return toret
+}
+
+func policiesForNetworkResource(resourceId string, allPolicies []*nmdata.Policy, groups map[string]*nmdata.Group) []*nmdata.Policy {
+	var toret []*nmdata.Policy
+
+	networkResourceGroups := networkResourceGroups(resourceId, groups)
+	for _, p := range allPolicies {
+		if p == nil || !p.Enabled || len(p.Rules) == 0 {
+			continue
+		}
+
+		// there's always only one rule in each policy
+		if p.Rules[0].DestinationResource.ID == resourceId {
+			toret = append(toret, p)
+			continue
+		}
+		for _, groupId := range networkResourceGroups {
+			if slices.Contains(p.Rules[0].Destinations, groupId) {
+				toret = append(toret, p)
+				break
+			}
+		}
+	}
+
+	return toret
+}
+
 // decodeAccountNetwork never returns nil — Calculate() dereferences
 // c.Network unconditionally, and servers that predate the fix omit the field
 // entirely from the empty-components envelope.
-func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network {
-	n := &types.Network{}
+func decodeAccountNetwork(an *proto.AccountNetwork) *nmdata.Network {
+	n := &nmdata.Network{}
 	if an == nil {
 		return n
 	}
 	n.Identifier = an.Identifier
 	n.Dns = an.Dns
-	n.Serial = an.Serial
+	n.Serial = int64(an.Serial)
 	if an.NetCidr != "" {
 		if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil {
 			n.Net = *ipnet
@@ -252,33 +313,51 @@ func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network {
 	return n
 }
 
-func decodeAccountSettings(as *proto.AccountSettingsCompact) *types.AccountSettingsInfo {
+func decodeAccountSettings(as *proto.AccountSettingsCompact) *nmdata.AccountSettingsInfo {
 	if as == nil {
-		return &types.AccountSettingsInfo{}
+		return &nmdata.AccountSettingsInfo{}
 	}
-	return &types.AccountSettingsInfo{
+	return &nmdata.AccountSettingsInfo{
 		PeerLoginExpirationEnabled: as.PeerLoginExpirationEnabled,
 		PeerLoginExpiration:        time.Duration(as.PeerLoginExpirationNs),
 	}
 }
 
-func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPeer {
-	peer := &types.ComponentPeer{
+func decodePeerCompact(pc *proto.PeerCompact, peerID string) *nmdata.Peer {
+	var caps []int32
+	if pc.SupportsSourcePrefixes {
+		caps = append(caps, nmdata.PeerCapabilitySourcePrefixes)
+	}
+	if pc.SupportsIpv6 {
+		caps = append(caps, nmdata.PeerCapabilityIPv6Overlay)
+	}
+	peer := &nmdata.Peer{
 		ID:                     peerID,
 		Key:                    peerID,
 		SSHKey:                 string(pc.SshPubKey),
 		SSHEnabled:             pc.SshEnabled,
 		DNSLabel:               pc.DnsLabel,
 		LoginExpirationEnabled: pc.LoginExpirationEnabled,
-		AgentVersion:           pc.AgentVersion,
-		SupportsSourcePrefixes: pc.SupportsSourcePrefixes,
-		SupportsIPv6:           pc.SupportsIpv6,
-		ServerSSHAllowed:       pc.ServerSshAllowed,
-		AddedWithSSOLogin:      pc.AddedWithSsoLogin,
-		ProxyEmbedded:          pc.ProxyEmbedded,
+		ProxyMeta:              nmdata.ProxyMeta{Embedded: pc.ProxyEmbedded},
+		Meta: nmdata.PeerSystemMeta{
+			WtVersion:    pc.AgentVersion,
+			Capabilities: caps,
+			Flags: nmdata.Flags{
+				ServerSSHAllowed: pc.ServerSshAllowed,
+			},
+		},
+	}
+	if pc.AddedWithSsoLogin {
+		// Set a non-empty UserID so (*Peer).AddedWithSSOLogin() returns true.
+		// The original UserID isn't on the wire; the value is intentionally
+		// visibly synthetic so any future consumer that mistakes UserID for a
+		// real account user xid won't silently match (or worse, write the
+		// sentinel into a downstream record).
+		peer.UserID = ""
 	}
 	if pc.LastLoginUnixNano != 0 {
-		peer.LastLogin = time.Unix(0, pc.LastLoginUnixNano)
+		t := time.Unix(0, pc.LastLoginUnixNano)
+		peer.LastLogin = &t
 	}
 	switch len(pc.Ip) {
 	case 4:
@@ -296,13 +375,13 @@ func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPee
 	return peer
 }
 
-func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex []string) *types.Policy {
-	rule := &types.PolicyRule{
+func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex []string) *nmdata.Policy {
+	rule := &nmdata.PolicyRule{
 		ID:                  policyID, // 1 rule per policy → reuse synthesized id
 		PolicyID:            policyID,
 		Enabled:             true,
-		Action:              actionFromProto(pc.Action),
-		Protocol:            protocolFromProto(pc.Protocol),
+		Action:              string(actionFromProto(pc.Action)),
+		Protocol:            string(protocolFromProto(pc.Protocol)),
 		Bidirectional:       pc.Bidirectional,
 		Ports:               uint32SliceToStrings(pc.Ports),
 		PortRanges:          portRangesFromProto(pc.PortRanges),
@@ -313,11 +392,11 @@ func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex
 		SourceResource:      resourceFromProto(pc.SourceResource, peerIDByIndex),
 		DestinationResource: resourceFromProto(pc.DestinationResource, peerIDByIndex),
 	}
-	return &types.Policy{
+	return &nmdata.Policy{
 		ID:                  policyID,
 		PublicID:            pc.Id,
 		Enabled:             true,
-		Rules:               []*types.PolicyRule{rule},
+		Rules:               []*nmdata.PolicyRule{rule},
 		SourcePostureChecks: pc.SourcePostureCheckIds,
 	}
 }
@@ -325,15 +404,19 @@ func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex
 // resourceFromProto rebuilds types.Resource. For peer-typed resources the
 // peer reference is reconstructed from the envelope's peer index — wire
 // format ships no xid for peers, so we use the synthesized peer id.
-func resourceFromProto(r *proto.ResourceCompact, peerIDByIndex []string) types.Resource {
-	if r == nil {
-		return types.Resource{}
+func resourceFromProto(r *proto.ResourceCompact, peerIDByIndex []string) nmdata.Resource {
+	if r == nil || !types.ResourceType(r.Type).Valid() {
+		return nmdata.Resource{}
 	}
-	out := types.Resource{Type: types.ResourceType(r.Type)}
-	if r.PeerIndexSet && int(r.PeerIndex) < len(peerIDByIndex) {
-		out.ID = peerIDByIndex[r.PeerIndex]
+
+	if r.Type == string(types.ResourceTypePeer) {
+		if !r.PeerIndexSet || int(r.PeerIndex) >= len(peerIDByIndex) {
+			return nmdata.Resource{}
+		}
+		return nmdata.Resource{Type: r.Type, ID: peerIDByIndex[int(r.PeerIndex)]}
 	}
-	return out
+
+	return nmdata.Resource{Type: r.Type, ID: r.Id}
 }
 
 // authorizedGroupsFromProto inverts encodeAuthorizedGroups: the wire form
@@ -354,15 +437,15 @@ func authorizedGroupsFromProto(m map[string]*proto.UserNameList) map[string][]st
 	return out
 }
 
-func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nbroute.Route {
-	r := &nbroute.Route{
-		ID:                  nbroute.ID(rr.Id),
+func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nmdata.Route {
+	r := &nmdata.Route{
+		ID:                  rr.Id,
 		PublicID:            rr.Id,
-		NetID:               nbroute.NetID(rr.NetId),
+		NetID:               rr.NetId,
 		Description:         rr.Description,
 		Domains:             domainsFromPunycode(rr.Domains),
 		KeepRoute:           rr.KeepRoute,
-		NetworkType:         nbroute.NetworkType(rr.NetworkType),
+		NetworkType:         int(rr.NetworkType),
 		Masquerade:          rr.Masquerade,
 		Metric:              int(rr.Metric),
 		Enabled:             rr.Enabled,
@@ -382,8 +465,8 @@ func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nbroute.Route {
 	return r
 }
 
-func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGroup {
-	out := &nbdns.NameServerGroup{
+func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nmdata.NameServerGroup {
+	out := &nmdata.NameServerGroup{
 		ID:                   nsg.Id,
 		PublicID:             nsg.Id,
 		Groups:               nsg.GroupIds,
@@ -391,13 +474,13 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr
 		Domains:              nsg.Domains,
 		Enabled:              nsg.Enabled,
 		SearchDomainsEnabled: nsg.SearchDomainsEnabled,
-		NameServers:          make([]nbdns.NameServer, 0, len(nsg.Nameservers)),
+		NameServers:          make([]nmdata.NameServer, 0, len(nsg.Nameservers)),
 	}
 	for _, ns := range nsg.Nameservers {
 		if addr, err := netip.ParseAddr(ns.IP); err == nil {
-			out.NameServers = append(out.NameServers, nbdns.NameServer{
+			out.NameServers = append(out.NameServers, nmdata.NameServer{
 				IP:     addr,
-				NSType: nbdns.NameServerType(ns.NSType),
+				NSType: int(ns.NSType),
 				Port:   int(ns.Port),
 			})
 		}
@@ -405,14 +488,14 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr
 	return out
 }
 
-func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResource {
-	out := &types.ComponentResource{
+func decodeNetworkResource(nr *proto.NetworkResourceRaw) *nmdata.NetworkResource {
+	out := &nmdata.NetworkResource{
 		ID:          nr.Id,
 		PublicID:    nr.Id,
 		NetworkID:   nr.NetworkSeq,
 		Name:        nr.Name,
 		Description: nr.Description,
-		Type:        types.ComponentResourceType(nr.Type),
+		Type:        nr.Type,
 		Address:     nr.Address,
 		Domain:      nr.DomainValue,
 		Enabled:     nr.Enabled,
@@ -425,10 +508,10 @@ func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResourc
 	return out
 }
 
-func decodeSimpleRecords(records []*proto.SimpleRecord) []nbdns.SimpleRecord {
-	out := make([]nbdns.SimpleRecord, 0, len(records))
+func decodeSimpleRecords(records []*proto.SimpleRecord) []nmdata.SimpleRecord {
+	out := make([]nmdata.SimpleRecord, 0, len(records))
 	for _, r := range records {
-		out = append(out, nbdns.SimpleRecord{
+		out = append(out, nmdata.SimpleRecord{
 			Name:  r.Name,
 			Type:  int(r.Type),
 			Class: r.Class,
@@ -439,10 +522,10 @@ func decodeSimpleRecords(records []*proto.SimpleRecord) []nbdns.SimpleRecord {
 	return out
 }
 
-func decodeCustomZones(zones []*proto.CustomZone) []nbdns.CustomZone {
-	out := make([]nbdns.CustomZone, 0, len(zones))
+func decodeCustomZones(zones []*proto.CustomZone) []nmdata.CustomZone {
+	out := make([]nmdata.CustomZone, 0, len(zones))
 	for _, z := range zones {
-		out = append(out, nbdns.CustomZone{
+		out = append(out, nmdata.CustomZone{
 			Domain:               z.Domain,
 			Records:              decodeSimpleRecords(z.Records),
 			SearchDomainDisabled: z.SearchDomainDisabled,
@@ -463,16 +546,16 @@ func uint32SliceToStrings(ports []uint32) []string {
 	return out
 }
 
-func portRangesFromProto(ranges []*proto.PortInfo_Range) []types.RulePortRange {
+func portRangesFromProto(ranges []*proto.PortInfo_Range) []nmdata.RulePortRange {
 	if len(ranges) == 0 {
 		return nil
 	}
-	out := make([]types.RulePortRange, 0, len(ranges))
+	out := make([]nmdata.RulePortRange, 0, len(ranges))
 	for _, r := range ranges {
 		if r == nil || r.Start > 65535 || r.End > 65535 {
 			continue
 		}
-		out = append(out, types.RulePortRange{
+		out = append(out, nmdata.RulePortRange{
 			Start: uint16(r.Start),
 			End:   uint16(r.End),
 		})
diff --git a/shared/management/networkmap/decode_test.go b/shared/management/networkmap/decode_test.go
new file mode 100644
index 000000000..7e2f17c60
--- /dev/null
+++ b/shared/management/networkmap/decode_test.go
@@ -0,0 +1,61 @@
+package networkmap
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	protobuf "google.golang.org/protobuf/proto"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/proto"
+)
+
+func TestDecodePolicy(t *testing.T) {
+	assert.Equal(t,
+		nmdata.Resource{Type: "peer", ID: "valid-id"},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: uint32(1)},
+			[]string{"invalid-id-0", "valid-id", "invalid-id-2"}))
+	// check invalid peer index returns an empty resource
+	assert.Equal(t,
+		nmdata.Resource{},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: uint32(100)},
+			[]string{"invalid-id-0", "valid-id", "invalid-id-2"}))
+	assert.Equal(t,
+		nmdata.Resource{Type: "domain", ID: "domain"},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "domain", Id: "domain"}, []string{}))
+	assert.Equal(t,
+		nmdata.Resource{Type: "host", ID: "host"},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "host", Id: "host"}, []string{}))
+	assert.Equal(t,
+		nmdata.Resource{Type: "subnet", ID: "subnet"},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "subnet", Id: "subnet"}, []string{}))
+	// an unknown resource type return an empty resource
+	assert.Equal(t,
+		nmdata.Resource{},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "boom", Id: "boom"}, []string{}))
+}
+
+// ResourceCompact fields 1-3 are the v0.77 wire contract. Retyping any of them
+// makes peers on either side of the change silently drop policy resources, so
+// the encoding is pinned here as raw bytes: field 1 "peer" (bytes), field 2
+// true (varint), field 3 7 (varint).
+func TestResourceCompactLegacyWireFormat(t *testing.T) {
+	legacy := []byte{0x0a, 0x04, 'p', 'e', 'e', 'r', 0x10, 0x01, 0x18, 0x07}
+
+	var decoded proto.ResourceCompact
+	require.NoError(t, protobuf.Unmarshal(legacy, &decoded))
+	assert.Equal(t, "peer", decoded.Type)
+	assert.True(t, decoded.PeerIndexSet)
+	assert.Equal(t, uint32(7), decoded.PeerIndex)
+
+	encoded, err := protobuf.Marshal(&proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: 7})
+	require.NoError(t, err)
+	assert.Equal(t, legacy, encoded)
+}
diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go
index 7f7f04204..dfacabe18 100644
--- a/shared/management/networkmap/encode.go
+++ b/shared/management/networkmap/encode.go
@@ -17,10 +17,11 @@ import (
 	log "github.com/sirupsen/logrus"
 	goproto "google.golang.org/protobuf/proto"
 
-	nbdns "github.com/netbirdio/netbird/dns"
 	"net/netip"
 
-	nbroute "github.com/netbirdio/netbird/route"
+	nbdns "github.com/netbirdio/netbird/dns"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/management/types"
 	"github.com/netbirdio/netbird/shared/netiputil"
@@ -28,7 +29,7 @@ import (
 )
 
 // ToProtocolRoutes converts a slice of typed routes to their proto form.
-func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route {
+func ToProtocolRoutes(routes []*nmdata.Route) []*proto.Route {
 	protoRoutes := make([]*proto.Route, 0, len(routes))
 	for _, r := range routes {
 		protoRoutes = append(protoRoutes, ToProtocolRoute(r))
@@ -37,7 +38,7 @@ func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route {
 }
 
 // ToProtocolRoute converts one typed route to its proto form.
-func ToProtocolRoute(route *nbroute.Route) *proto.Route {
+func ToProtocolRoute(route *nmdata.Route) *proto.Route {
 	return &proto.Route{
 		ID:            string(route.ID),
 		NetID:         string(route.NetID),
@@ -274,7 +275,7 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort
 // AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig
 // entries to dst and returns the result. localIsProxy reports whether the peer
 // receiving this config is itself an embedded proxy.
-func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.ComponentPeer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig {
+func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nmdata.Peer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig {
 	for _, rPeer := range peers {
 		allowedIPs := []string{rPeer.IP.String() + "/32"}
 		if includeIPv6 && rPeer.IPv6.IsValid() {
@@ -285,7 +286,7 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.Compon
 			AllowedIps:   allowedIPs,
 			SshConfig:    &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)},
 			Fqdn:         rPeer.FQDN(dnsName),
-			AgentVersion: rPeer.AgentVersion,
+			AgentVersion: rPeer.Meta.WtVersion,
 			LazyState:    lazyStateFor(localIsProxy, rPeer),
 		})
 	}
@@ -297,8 +298,8 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.Compon
 // proxy infrastructure is not kept permanently connected to every peer. All
 // other peers follow the account-wide flag. A future admin-facing per-peer
 // setting can return LazyStateEager here to force a peer always-active.
-func lazyStateFor(localIsProxy bool, rPeer *types.ComponentPeer) proto.LazyState {
-	if localIsProxy || rPeer.ProxyEmbedded {
+func lazyStateFor(localIsProxy bool, rPeer *nmdata.Peer) proto.LazyState {
+	if localIsProxy || rPeer.ProxyMeta.Embedded {
 		return proto.LazyState_LazyStateLazy
 	}
 	return proto.LazyState_LazyStateDefault
diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go
index cd3f862ec..e7961fd7b 100644
--- a/shared/management/networkmap/envelope.go
+++ b/shared/management/networkmap/envelope.go
@@ -36,7 +36,7 @@ type EnvelopeResult struct {
 // dnsName is the account's DNS domain ("netbird.cloud" etc.); used when
 // rebuilding the per-peer FQDNs that proto.RemotePeerConfig carries.
 func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, localPeerKey, dnsName string) (*EnvelopeResult, error) {
-	components, err := DecodeEnvelope(env)
+	components, err := DecodeEnvelope(ctx, env)
 	if err != nil {
 		return nil, fmt.Errorf("decode envelope: %w", err)
 	}
@@ -54,8 +54,8 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo
 	}
 	components.PeerID = canonicalKey
 
-	includeIPv6 := localPeer.SupportsIPv6 && localPeer.IPv6.IsValid()
-	useSourcePrefixes := localPeer.SupportsSourcePrefixes
+	includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid()
+	useSourcePrefixes := localPeer.SupportsSourcePrefixes()
 
 	typedNM := components.Calculate(ctx)
 
@@ -74,11 +74,11 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo
 	protoNM.Routes = ToProtocolRoutes(typedNM.Routes)
 	protoNM.DNSConfig = ToProtocolDNSConfig(typedNM.DNSConfig, nil, dnsFwdPort)
 
-	remotePeers := AppendRemotePeerConfig(nil, typedNM.Peers, dnsName, includeIPv6, localPeer.ProxyEmbedded)
+	remotePeers := AppendRemotePeerConfig(nil, typedNM.Peers, dnsName, includeIPv6, localPeer.ProxyMeta.Embedded)
 	protoNM.RemotePeers = remotePeers
 	protoNM.RemotePeersIsEmpty = len(remotePeers) == 0
 
-	protoNM.OfflinePeers = AppendRemotePeerConfig(nil, typedNM.OfflinePeers, dnsName, includeIPv6, localPeer.ProxyEmbedded)
+	protoNM.OfflinePeers = AppendRemotePeerConfig(nil, typedNM.OfflinePeers, dnsName, includeIPv6, localPeer.ProxyMeta.Embedded)
 
 	firewallRules := ToProtocolFirewallRules(typedNM.FirewallRules, includeIPv6, useSourcePrefixes)
 	protoNM.FirewallRules = firewallRules
diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go
index 92e1916da..7fe2a5277 100644
--- a/shared/management/networkmap/envelope_test.go
+++ b/shared/management/networkmap/envelope_test.go
@@ -15,6 +15,7 @@ import (
 	mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
 	"github.com/netbirdio/netbird/management/server/types"
 	nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -55,13 +56,13 @@ func TestEnvelopeToNetworkMap_RoundTrip(t *testing.T) {
 func TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH(t *testing.T) {
 	c, localPeerKey := buildSmokeComponents(t)
 	// Replace the smoke policy with a NetbirdSSH-protocol allow.
-	c.Policies = []*types.Policy{{
+	c.Policies = []*nmdata.Policy{{
 		ID: "pol-ssh", PublicID: "2", Enabled: true,
-		Rules: []*types.PolicyRule{{
+		Rules: []*nmdata.PolicyRule{{
 			ID:            "rule-ssh",
 			Enabled:       true,
-			Action:        types.PolicyTrafficActionAccept,
-			Protocol:      types.PolicyRuleProtocolNetbirdSSH,
+			Action:        string(types.PolicyTrafficActionAccept),
+			Protocol:      string(types.PolicyRuleProtocolNetbirdSSH),
 			Bidirectional: true,
 			Sources:       []string{"group-all"},
 			Destinations:  []string{"group-all"},
@@ -143,39 +144,39 @@ func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) {
 func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) {
 	ctx := context.Background()
 
-	peers := map[string]*types.ComponentPeer{}
+	peers := map[string]*nmdata.Peer{}
 	for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} {
-		peers[id] = &types.ComponentPeer{
-			ID:           id,
-			Key:          randomWgKey(t),
-			IP:           netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}),
-			DNSLabel:     id,
-			AgentVersion: "0.40.0",
+		peers[id] = &nmdata.Peer{
+			ID:       id,
+			Key:      randomWgKey(t),
+			IP:       netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}),
+			DNSLabel: id,
+			Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 		}
 	}
 
 	c := &types.NetworkMapComponents{
 		PeerID: "peer-T",
-		Network: &types.Network{
+		Network: &nmdata.Network{
 			Identifier: "net-all-groups",
 			Net:        net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
 			Serial:     1,
 		},
-		AccountSettings: &types.AccountSettingsInfo{},
-		DNSSettings:     &types.DNSSettings{},
+		AccountSettings: &nmdata.AccountSettingsInfo{},
+		DNSSettings:     &nmdata.DNSSettings{},
 		Peers:           peers,
-		Groups: map[string]*types.ComponentGroup{
-			"g-src": {ID: "g-src", PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}},
-			"g-all": {ID: "g-all", PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}},
-			"g-two": {ID: "g-two", PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}},
+		Groups: map[string]*nmdata.Group{
+			"g-src": {PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}},
+			"g-all": {PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}},
+			"g-two": {PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}},
 		},
-		Policies: []*types.Policy{{
+		Policies: []*nmdata.Policy{{
 			ID: "pol-multi-dest", PublicID: "10", Enabled: true,
-			Rules: []*types.PolicyRule{{
+			Rules: []*nmdata.PolicyRule{{
 				ID:           "rule-multi-dest",
 				Enabled:      true,
-				Action:       types.PolicyTrafficActionAccept,
-				Protocol:     types.PolicyRuleProtocolALL,
+				Action:       string(types.PolicyTrafficActionAccept),
+				Protocol:     string(types.PolicyRuleProtocolALL),
 				Sources:      []string{"g-src"},
 				Destinations: []string{"g-all", "g-two"},
 			}},
@@ -231,12 +232,12 @@ func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) {
 	localPeerKey := randomWgKey(t)
 	c := types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
 		PeerID: "peer-A",
-		Network: &types.Network{
+		Network: &nmdata.Network{
 			Identifier: "net-empty",
 			Net:        net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
 			Serial:     7,
 		},
-		Peers: map[string]*types.ComponentPeer{
+		Peers: map[string]*nmdata.Peer{
 			"peer-A": {ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})},
 		},
 	})
@@ -291,33 +292,33 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) {
 	peerAKey := randomWgKey(t)
 	peerBKey := randomWgKey(t)
 
-	peerA := &types.ComponentPeer{
-		ID:           "peer-A",
-		Key:          peerAKey,
-		IP:           netip.AddrFrom4([4]byte{100, 64, 0, 1}),
-		DNSLabel:     "peerA",
-		AgentVersion: "0.40.0",
+	peerA := &nmdata.Peer{
+		ID:       "peer-A",
+		Key:      peerAKey,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, 1}),
+		DNSLabel: "peerA",
+		Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 	}
-	peerB := &types.ComponentPeer{
-		ID:           "peer-B",
-		Key:          peerBKey,
-		IP:           netip.AddrFrom4([4]byte{100, 64, 0, 2}),
-		DNSLabel:     "peerB",
-		AgentVersion: "0.40.0",
+	peerB := &nmdata.Peer{
+		ID:       "peer-B",
+		Key:      peerBKey,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, 2}),
+		DNSLabel: "peerB",
+		Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 	}
 
-	group := &types.ComponentGroup{
-		ID: "group-all", PublicID: "1", Name: "All",
+	group := &nmdata.Group{
+		PublicID: "1", Name: "All",
 		Peers: []string{"peer-A", "peer-B"},
 	}
 
-	policy := &types.Policy{
+	policy := &nmdata.Policy{
 		ID: "pol-allow", PublicID: "1", Enabled: true,
-		Rules: []*types.PolicyRule{{
+		Rules: []*nmdata.PolicyRule{{
 			ID:            "rule-allow",
 			Enabled:       true,
-			Action:        types.PolicyTrafficActionAccept,
-			Protocol:      types.PolicyRuleProtocolALL,
+			Action:        string(types.PolicyTrafficActionAccept),
+			Protocol:      string(types.PolicyRuleProtocolALL),
 			Bidirectional: true,
 			Sources:       []string{"group-all"},
 			Destinations:  []string{"group-all"},
@@ -326,21 +327,21 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) {
 
 	c := &types.NetworkMapComponents{
 		PeerID: "peer-A",
-		Network: &types.Network{
+		Network: &nmdata.Network{
 			Identifier: "net-smoke",
 			Net:        net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
 			Serial:     1,
 		},
-		AccountSettings: &types.AccountSettingsInfo{},
-		DNSSettings:     &types.DNSSettings{},
-		Peers: map[string]*types.ComponentPeer{
+		AccountSettings: &nmdata.AccountSettingsInfo{},
+		DNSSettings:     &nmdata.DNSSettings{},
+		Peers: map[string]*nmdata.Peer{
 			"peer-A": peerA,
 			"peer-B": peerB,
 		},
-		Groups: map[string]*types.ComponentGroup{
+		Groups: map[string]*nmdata.Group{
 			"group-all": group,
 		},
-		Policies: []*types.Policy{policy},
+		Policies: []*nmdata.Policy{policy},
 	}
 	return c, peerAKey
 }
diff --git a/shared/management/networkmap/networkmapcompute.go b/shared/management/networkmap/networkmapcompute.go
new file mode 100644
index 000000000..1cf7aeef4
--- /dev/null
+++ b/shared/management/networkmap/networkmapcompute.go
@@ -0,0 +1,812 @@
+package networkmap
+
+import (
+	"slices"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/types"
+)
+
+type sshRequirements struct {
+	neededGroupIDs     map[string]struct{}
+	needAllowedUserIDs bool
+}
+
+// GetPeerNetworkMapComponents computes the peer's NetworkMapComponents from the
+// slim twin store. It mirrors the former Account.GetPeerNetworkMapComponents
+// exactly, operating on nmdata twins throughout — no Account reference and no
+// twin↔real conversion, since the produced components hold twins.
+func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCustomZone nmdata.CustomZone) *types.NetworkMapComponents {
+	nmd.InjectProxyPolicies()
+
+	forceRoutingPeerDNS := nmd.forcesRoutingPeerDNSResolution(peerID)
+
+	peer := nmd.Peers[peerID]
+	if peer == nil {
+		return types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
+			PeerID:                        peerID,
+			Network:                       nmd.Network,
+			Peers:                         map[string]*nmdata.Peer{peerID: peer},
+			ForceRoutingPeerDNSResolution: forceRoutingPeerDNS,
+		})
+	}
+
+	if _, ok := nmd.ValidatedPeers[peerID]; !ok {
+		return types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
+			PeerID:                        peerID,
+			Network:                       nmd.Network,
+			Peers:                         map[string]*nmdata.Peer{peerID: peer},
+			ForceRoutingPeerDNSResolution: forceRoutingPeerDNS,
+		})
+	}
+
+	components := &types.NetworkMapComponents{
+		PeerID:                        peerID,
+		Network:                       nmd.Network,
+		AccountSettings:               nmd.AccountSettings,
+		DNSSettings:                   nmd.DNSSettings,
+		CustomZoneDomain:              peersCustomZone.Domain,
+		NameServerGroups:              make([]*nmdata.NameServerGroup, 0),
+		ResourcePoliciesMap:           make(map[string][]*nmdata.Policy),
+		RoutersMap:                    make(map[string]map[string]*nmdata.NetworkRouter),
+		NetworkResources:              make([]*nmdata.NetworkResource, 0),
+		PostureFailedPeers:            make(map[string]map[string]struct{}, len(nmd.PostureChecks)),
+		RouterPeers:                   make(map[string]*nmdata.Peer),
+		NetworkXIDToPublicID:          nmd.NetworkXIDToPublicID,
+		PostureCheckXIDToPublicID:     nmd.PostureCheckXIDToPublicID,
+		ForceRoutingPeerDNSResolution: forceRoutingPeerDNS,
+	}
+
+	relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := nmd.getPeersGroupsPoliciesRoutes(peerID, peer.SSHEnabled, &components.PostureFailedPeers)
+
+	if len(sshReqs.neededGroupIDs) > 0 {
+		components.GroupIDToUserIDs = filterGroupIDToUserIDs(nmd.GroupIDToUserIDs, sshReqs.neededGroupIDs)
+	}
+	if sshReqs.needAllowedUserIDs {
+		components.AllowedUserIDs = nmd.getAllowedUserIDs()
+	}
+
+	components.Peers = relevantPeers
+	components.Groups = relevantGroups
+	components.Policies = relevantPolicies
+	components.Routes = relevantRoutes
+	components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
+
+	peerGroups := nmd.GetPeerGroups(peerID)
+	components.AccountZones = nmd.appliedZones(peerGroups)
+	components.AccountZones = append(components.AccountZones, nmd.privateServiceZones(peerGroups)...)
+
+	for _, nsGroup := range nmd.NameServerGroups {
+		if nsGroup != nil && nsGroup.Enabled {
+			for _, gID := range nsGroup.Groups {
+				if _, found := relevantGroups[gID]; found {
+					components.NameServerGroups = append(components.NameServerGroups, nsGroup)
+					break
+				}
+			}
+		}
+	}
+
+	for _, resource := range nmd.NetworkResources {
+		if resource == nil || !resource.Enabled {
+			continue
+		}
+
+		policies, exists := nmd.ResourcePolicies[resource.ID]
+		if !exists {
+			continue
+		}
+
+		addSourcePeers := false
+
+		networkRoutingPeers, routerExists := nmd.Routers[resource.NetworkID]
+		if routerExists {
+			if _, ok := networkRoutingPeers[peerID]; ok {
+				addSourcePeers = true
+			}
+		}
+
+		for _, policy := range policies {
+			if policy == nil || !policy.Enabled || len(policy.Rules) == 0 || policy.Rules[0] == nil {
+				continue
+			}
+			if addSourcePeers {
+				var peers []string
+				if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" {
+					peers = []string{policy.Rules[0].SourceResource.ID}
+				} else {
+					peers = nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
+				}
+				for _, pID := range nmd.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, &components.PostureFailedPeers) {
+					if _, exists := components.Peers[pID]; !exists {
+						components.Peers[pID] = nmd.Peers[pID]
+					}
+				}
+			} else {
+				peerInSources := false
+				if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" {
+					peerInSources = policy.Rules[0].SourceResource.ID == peerID
+				} else {
+					for _, groupID := range policy.SourceGroups() {
+						if group := nmd.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
+							peerInSources = true
+							break
+						}
+					}
+				}
+				if !peerInSources {
+					continue
+				}
+				isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(policy.SourcePostureChecks, peerID)
+				if !isValid && len(pname) > 0 {
+					if _, ok := components.PostureFailedPeers[pname]; !ok {
+						components.PostureFailedPeers[pname] = make(map[string]struct{})
+					}
+					components.PostureFailedPeers[pname][peer.ID] = struct{}{}
+					continue
+				}
+				addSourcePeers = true
+			}
+
+			for _, rule := range policy.Rules {
+				if rule == nil || !rule.Enabled {
+					continue
+				}
+				for _, srcGroupID := range rule.Sources {
+					if g := nmd.Groups[srcGroupID]; g != nil {
+						if _, exists := components.Groups[srcGroupID]; !exists {
+							components.Groups[srcGroupID] = g
+						}
+					}
+				}
+				for _, dstGroupID := range rule.Destinations {
+					if g := nmd.Groups[dstGroupID]; g != nil {
+						if _, exists := components.Groups[dstGroupID]; !exists {
+							components.Groups[dstGroupID] = g
+						}
+					}
+				}
+			}
+			components.ResourcePoliciesMap[resource.ID] = policies
+		}
+
+		if addSourcePeers {
+			components.RoutersMap[resource.NetworkID] = networkRoutingPeers
+			for peerIDKey := range networkRoutingPeers {
+				p := nmd.Peers[peerIDKey]
+				if p == nil {
+					continue
+				}
+				// An unapproved peer must not carry traffic, so it is kept out of
+				// RouterPeers as well: the envelope encoder indexes that map into
+				// the wire peer table, from which the client restores every entry.
+				if _, validated := nmd.ValidatedPeers[peerIDKey]; !validated {
+					continue
+				}
+				if _, exists := components.RouterPeers[peerIDKey]; !exists {
+					components.RouterPeers[peerIDKey] = p
+				}
+				if _, exists := components.Peers[peerIDKey]; !exists {
+					components.Peers[peerIDKey] = p
+				}
+			}
+			components.NetworkResources = append(components.NetworkResources, resource)
+		}
+	}
+
+	filterGroupPeers(&components.Groups, components.Peers)
+	filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers)
+
+	return components
+}
+
+func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes(
+	peerID string,
+	peerSSHEnabled bool,
+	postureFailedPeers *map[string]map[string]struct{},
+) (map[string]*nmdata.Peer, map[string]*nmdata.Group, []*nmdata.Policy, []*nmdata.Route, sshRequirements) {
+	relevantPeerIDs := make(map[string]*nmdata.Peer, len(nmd.Peers)/4)
+	relevantGroupIDs := make(map[string]*nmdata.Group, len(nmd.Groups)/4)
+	relevantPolicies := make([]*nmdata.Policy, 0, len(nmd.Policies))
+	relevantRoutes := make([]*nmdata.Route, 0, len(nmd.Routes))
+	sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})}
+
+	relevantPeerIDs[peerID] = nmd.Peers[peerID]
+
+	peerGroupSet := nmd.GetPeerGroups(peerID)
+	for groupID := range peerGroupSet {
+		relevantGroupIDs[groupID] = nmd.Groups[groupID]
+	}
+
+	routeAccessControlGroups := make(map[string]struct{})
+	for _, r := range nmd.Routes {
+		if r == nil {
+			continue
+		}
+		relevant := r.Peer == peerID
+		if !relevant {
+			for _, groupID := range r.PeerGroups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant && r.Enabled {
+			for _, groupID := range r.Groups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant {
+			continue
+		}
+
+		for _, groupID := range r.PeerGroups {
+			if g := nmd.Groups[groupID]; g != nil {
+				relevantGroupIDs[groupID] = g
+			}
+		}
+		for _, groupID := range r.Groups {
+			if g := nmd.Groups[groupID]; g != nil {
+				relevantGroupIDs[groupID] = g
+			}
+		}
+		if r.Enabled {
+			for _, groupID := range r.AccessControlGroups {
+				if g := nmd.Groups[groupID]; g != nil {
+					relevantGroupIDs[groupID] = g
+				}
+				routeAccessControlGroups[groupID] = struct{}{}
+			}
+		}
+
+		if r.Peer != "" {
+			if _, ok := nmd.ValidatedPeers[r.Peer]; ok {
+				if p := nmd.Peers[r.Peer]; p != nil {
+					relevantPeerIDs[r.Peer] = p
+				}
+			}
+		}
+		for _, groupID := range r.PeerGroups {
+			g := nmd.Groups[groupID]
+			if g == nil {
+				continue
+			}
+			for _, pid := range g.Peers {
+				if _, exists := relevantPeerIDs[pid]; exists {
+					continue
+				}
+				if _, ok := nmd.ValidatedPeers[pid]; !ok {
+					continue
+				}
+				if p := nmd.Peers[pid]; p != nil {
+					relevantPeerIDs[pid] = p
+				}
+			}
+		}
+		relevantRoutes = append(relevantRoutes, r)
+	}
+
+	for _, policy := range nmd.Policies {
+		if policy == nil || !policy.Enabled {
+			continue
+		}
+
+		policyRelevant := false
+		for _, rule := range policy.Rules {
+			if rule == nil || !rule.Enabled {
+				continue
+			}
+
+			if len(routeAccessControlGroups) > 0 {
+				for _, destGroupID := range rule.Destinations {
+					if _, needed := routeAccessControlGroups[destGroupID]; needed {
+						policyRelevant = true
+						for _, srcGroupID := range rule.Sources {
+							if g := nmd.Groups[srcGroupID]; g != nil {
+								relevantGroupIDs[srcGroupID] = g
+							}
+						}
+						for _, dstGroupID := range rule.Destinations {
+							if g := nmd.Groups[dstGroupID]; g != nil {
+								relevantGroupIDs[dstGroupID] = g
+							}
+						}
+						break
+					}
+				}
+			}
+
+			var sourcePeers, destinationPeers []string
+			var peerInSources, peerInDestinations bool
+
+			if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" {
+				sourcePeers = []string{rule.SourceResource.ID}
+				if rule.SourceResource.ID == peerID {
+					peerInSources = true
+				}
+			} else {
+				sourcePeers, peerInSources = nmd.getPeersFromGroups(rule.Sources, peerID, policy.SourcePostureChecks, postureFailedPeers)
+			}
+
+			if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
+				destinationPeers = []string{rule.DestinationResource.ID}
+				if rule.DestinationResource.ID == peerID {
+					peerInDestinations = true
+				}
+			} else {
+				destinationPeers, peerInDestinations = nmd.getPeersFromGroups(rule.Destinations, peerID, nil, postureFailedPeers)
+			}
+
+			if peerInSources {
+				policyRelevant = true
+				for _, pid := range destinationPeers {
+					relevantPeerIDs[pid] = nmd.Peers[pid]
+				}
+				for _, dstGroupID := range rule.Destinations {
+					if g := nmd.Groups[dstGroupID]; g != nil {
+						relevantGroupIDs[dstGroupID] = g
+					}
+				}
+			}
+
+			if peerInDestinations {
+				policyRelevant = true
+				for _, pid := range sourcePeers {
+					relevantPeerIDs[pid] = nmd.Peers[pid]
+				}
+				for _, srcGroupID := range rule.Sources {
+					if g := nmd.Groups[srcGroupID]; g != nil {
+						relevantGroupIDs[srcGroupID] = g
+					}
+				}
+
+				if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
+					switch {
+					case len(rule.AuthorizedGroups) > 0:
+						for groupID := range rule.AuthorizedGroups {
+							sshReqs.neededGroupIDs[groupID] = struct{}{}
+						}
+					case rule.AuthorizedUser != "":
+					default:
+						sshReqs.needAllowedUserIDs = true
+					}
+				} else if nmdata.PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled {
+					sshReqs.needAllowedUserIDs = true
+				}
+			}
+		}
+		if policyRelevant {
+			relevantPolicies = append(relevantPolicies, policy)
+		}
+	}
+
+	return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs
+}
+
+func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string,
+	postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
+	peerInGroups := false
+	filteredPeerIDs := make([]string, 0, len(groups))
+	seenPeerIds := make(map[string]struct{}, len(groups))
+
+	for _, gid := range groups {
+		group := nmd.Groups[gid]
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			filteredPeerIDs = make([]string, 0, len(group.Peers))
+			peerInGroups = false
+			for _, pid := range group.Peers {
+				peer, ok := nmd.Peers[pid]
+				if !ok || peer == nil {
+					continue
+				}
+
+				if _, ok := nmd.ValidatedPeers[peer.ID]; !ok {
+					continue
+				}
+
+				isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, peer.ID)
+				if !isValid && len(pname) > 0 {
+					if _, ok := (*postureFailedPeers)[pname]; !ok {
+						(*postureFailedPeers)[pname] = make(map[string]struct{})
+					}
+					(*postureFailedPeers)[pname][peer.ID] = struct{}{}
+					continue
+				}
+
+				if peer.ID == peerID {
+					peerInGroups = true
+					continue
+				}
+
+				filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+			}
+			return filteredPeerIDs, peerInGroups
+		}
+
+		for _, pid := range group.Peers {
+			if _, seen := seenPeerIds[pid]; seen {
+				continue
+			}
+			seenPeerIds[pid] = struct{}{}
+			peer, ok := nmd.Peers[pid]
+			if !ok || peer == nil {
+				continue
+			}
+
+			if _, ok := nmd.ValidatedPeers[peer.ID]; !ok {
+				continue
+			}
+
+			isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, peer.ID)
+			if !isValid && len(pname) > 0 {
+				if _, ok := (*postureFailedPeers)[pname]; !ok {
+					(*postureFailedPeers)[pname] = make(map[string]struct{})
+				}
+				(*postureFailedPeers)[pname][peer.ID] = struct{}{}
+				continue
+			}
+
+			if peer.ID == peerID {
+				peerInGroups = true
+				continue
+			}
+
+			filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+		}
+	}
+
+	return filteredPeerIDs, peerInGroups
+}
+
+func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChecksID []string, peerID string) (bool, string) {
+	peer, ok := nmd.Peers[peerID]
+	if !ok || peer == nil {
+		return false, ""
+	}
+
+	for _, postureChecksID := range sourcePostureChecksID {
+		if valid, cached := nmd.cachedPostureCheckResult(postureChecksID, peerID); cached {
+			if !valid {
+				return false, postureChecksID
+			}
+			continue
+		}
+
+		postureChecks := nmd.PostureChecks[postureChecksID]
+		if postureChecks == nil {
+			continue
+		}
+		if !postureChecks.Passes(peer) {
+			return false, postureChecksID
+		}
+	}
+	return true, ""
+}
+
+func (nmd *NetworkMapData) PrecomputePostureValidation() {
+	if len(nmd.PostureChecks) == 0 {
+		nmd.PostureValidation = nil
+		return
+	}
+
+	checkPeerIDs := make(map[string]map[string]struct{})
+	for _, policy := range nmd.Policies {
+		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
+			continue
+		}
+
+		groupPeerIDs := nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
+		for _, postureChecksID := range policy.SourcePostureChecks {
+			set := checkPeerIDs[postureChecksID]
+			if set == nil {
+				set = make(map[string]struct{}, len(groupPeerIDs))
+				checkPeerIDs[postureChecksID] = set
+			}
+			for _, pid := range groupPeerIDs {
+				set[pid] = struct{}{}
+			}
+			for _, rule := range policy.Rules {
+				if rule == nil {
+					continue
+				}
+				if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" {
+					set[rule.SourceResource.ID] = struct{}{}
+				}
+			}
+		}
+	}
+
+	results := make(map[string]map[string]bool, len(checkPeerIDs))
+	for postureChecksID, peerIDs := range checkPeerIDs {
+		results[postureChecksID] = nmd.evaluatePostureChecksForPeers(postureChecksID, peerIDs)
+	}
+	nmd.PostureValidation = results
+}
+
+func (nmd *NetworkMapData) evaluatePostureChecksForPeers(postureChecksID string, peerIDs map[string]struct{}) map[string]bool {
+	postureChecks := nmd.PostureChecks[postureChecksID]
+	if postureChecks == nil {
+		return nil
+	}
+
+	checks := postureChecks.GetChecks()
+	results := make(map[string]bool, len(peerIDs))
+	for peerID := range peerIDs {
+		peer := nmd.Peers[peerID]
+		if peer == nil {
+			continue
+		}
+		results[peerID] = nmdata.PassesChecks(checks, peer)
+	}
+	return results
+}
+
+func (nmd *NetworkMapData) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) {
+	results, ok := nmd.PostureValidation[postureChecksID]
+	if !ok {
+		return false, false
+	}
+	if results == nil {
+		return true, true
+	}
+	valid, found := results[peerID]
+	return valid, found
+}
+
+func (nmd *NetworkMapData) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) []string {
+	var dest []string
+	for _, peerID := range inputPeers {
+		if _, validated := nmd.ValidatedPeers[peerID]; !validated {
+			continue
+		}
+		valid, pname := nmd.validatePostureChecksOnPeerGetFailed(postureChecksIDs, peerID)
+		if valid {
+			dest = append(dest, peerID)
+			continue
+		}
+		if pname == "" {
+			continue
+		}
+		if _, ok := (*postureFailedPeers)[pname]; !ok {
+			(*postureFailedPeers)[pname] = make(map[string]struct{})
+		}
+		(*postureFailedPeers)[pname][peerID] = struct{}{}
+	}
+	return dest
+}
+
+// forcesRoutingPeerDNSResolution reports whether the given peer must run
+// routing-peer DNS resolution regardless of the account-global
+// RoutingPeerDNSResolutionEnabled setting: true when the peer routes a domain
+// network resource targeted by an enabled reverse-proxy service, so the peer's
+// DNS forwarder starts and can resolve the target for the embedded proxy peers.
+func (nmd *NetworkMapData) forcesRoutingPeerDNSResolution(peerID string) bool {
+	if len(nmd.ProxyTargetedDomainResourceIDs) == 0 {
+		return false
+	}
+
+	for _, resource := range nmd.NetworkResources {
+		if resource == nil || !resource.Enabled || resource.Type != string(types.ResourceTypeDomain) {
+			continue
+		}
+		if _, ok := nmd.ProxyTargetedDomainResourceIDs[resource.ID]; !ok {
+			continue
+		}
+		if _, isRouter := nmd.Routers[resource.NetworkID][peerID]; isRouter {
+			return true
+		}
+	}
+
+	return false
+}
+
+// GetPeerGroups returns the set of group IDs the peer belongs to. The
+// underlying peer→groups index is built once per NetworkMapData and the
+// returned set is shared — callers must not mutate it.
+func (nmd *NetworkMapData) GetPeerGroups(peerID string) map[string]struct{} {
+	nmd.peerGroupsOnce.Do(func() {
+		idx := make(map[string]map[string]struct{}, len(nmd.Peers))
+		for groupID, group := range nmd.Groups {
+			if group == nil {
+				continue
+			}
+			for _, pid := range group.Peers {
+				set, ok := idx[pid]
+				if !ok {
+					set = make(map[string]struct{})
+					idx[pid] = set
+				}
+				set[groupID] = struct{}{}
+			}
+		}
+		nmd.peerGroupsIdx = idx
+	})
+
+	if set, ok := nmd.peerGroupsIdx[peerID]; ok {
+		return set
+	}
+	return map[string]struct{}{}
+}
+
+func (nmd *NetworkMapData) getUniquePeerIDsFromGroupsIDs(groups []string) []string {
+	peerIDs := make(map[string]struct{}, len(groups))
+	for _, groupID := range groups {
+		group := nmd.Groups[groupID]
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			return group.Peers
+		}
+
+		for _, peerID := range group.Peers {
+			peerIDs[peerID] = struct{}{}
+		}
+	}
+
+	ids := make([]string, 0, len(peerIDs))
+	for peerID := range peerIDs {
+		ids = append(ids, peerID)
+	}
+
+	return ids
+}
+
+func (nmd *NetworkMapData) getAllowedUserIDs() map[string]struct{} {
+	return nmd.AllowedUserIDs
+}
+
+func (nmd *NetworkMapData) appliedZones(peerGroups map[string]struct{}) []nmdata.CustomZone {
+	if len(peerGroups) == 0 {
+		return nil
+	}
+	var out []nmdata.CustomZone
+	for _, cand := range nmd.AppliedZoneCandidates {
+		if peerInDistributionGroups(peerGroups, cand.DistributionGroups) {
+			out = append(out, cand.Zone)
+		}
+	}
+	return out
+}
+
+func (nmd *NetworkMapData) privateServiceZones(peerGroups map[string]struct{}) []nmdata.CustomZone {
+	byApex := make(map[string]*nmdata.CustomZone)
+	var order []string
+	for _, cand := range nmd.PrivateServiceCandidates {
+		if !peerInDistributionGroups(peerGroups, cand.AccessGroups) {
+			continue
+		}
+		zone, exists := byApex[cand.Zone.Domain]
+		if !exists {
+			nz := nmdata.CustomZone{
+				Domain:               cand.Zone.Domain,
+				SearchDomainDisabled: cand.Zone.SearchDomainDisabled,
+				NonAuthoritative:     cand.Zone.NonAuthoritative,
+			}
+			byApex[cand.Zone.Domain] = &nz
+			zone = &nz
+			order = append(order, cand.Zone.Domain)
+		}
+		zone.Records = append(zone.Records, cand.Zone.Records...)
+	}
+
+	var out []nmdata.CustomZone
+	for _, apex := range order {
+		zone := byApex[apex]
+		if len(zone.Records) == 0 {
+			continue
+		}
+		out = append(out, *zone)
+	}
+	return out
+}
+
+func peerInDistributionGroups(peerGroups map[string]struct{}, groups []string) bool {
+	for _, g := range groups {
+		if _, ok := peerGroups[g]; ok {
+			return true
+		}
+	}
+	return false
+}
+
+func filterGroupPeers(groups *map[string]*nmdata.Group, peers map[string]*nmdata.Peer) {
+	for groupID, groupInfo := range *groups {
+		filteredPeers := make([]string, 0, len(groupInfo.Peers))
+		for _, pid := range groupInfo.Peers {
+			if _, exists := peers[pid]; exists {
+				filteredPeers = append(filteredPeers, pid)
+			}
+		}
+
+		if len(filteredPeers) != len(groupInfo.Peers) {
+			ng := groupInfo.Copy()
+			ng.Peers = filteredPeers
+			(*groups)[groupID] = ng
+		}
+	}
+}
+
+func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*nmdata.Policy, resourcePoliciesMap map[string][]*nmdata.Policy, peers map[string]*nmdata.Peer) {
+	if len(*postureFailedPeers) == 0 {
+		return
+	}
+
+	referencedPostureChecks := make(map[string]struct{})
+	for _, policy := range policies {
+		for _, checkID := range policy.SourcePostureChecks {
+			referencedPostureChecks[checkID] = struct{}{}
+		}
+	}
+	for _, resPolicies := range resourcePoliciesMap {
+		for _, policy := range resPolicies {
+			for _, checkID := range policy.SourcePostureChecks {
+				referencedPostureChecks[checkID] = struct{}{}
+			}
+		}
+	}
+
+	for checkID, failedPeers := range *postureFailedPeers {
+		if _, referenced := referencedPostureChecks[checkID]; !referenced {
+			delete(*postureFailedPeers, checkID)
+			continue
+		}
+		for peerID := range failedPeers {
+			if _, exists := peers[peerID]; !exists {
+				delete(failedPeers, peerID)
+			}
+		}
+		if len(failedPeers) == 0 {
+			delete(*postureFailedPeers, checkID)
+		}
+	}
+}
+
+func filterDNSRecordsByPeers(records []nmdata.SimpleRecord, peers map[string]*nmdata.Peer, includeIPv6 bool) []nmdata.SimpleRecord {
+	if len(records) == 0 || len(peers) == 0 {
+		return nil
+	}
+
+	peerIPs := make(map[string]struct{}, len(peers)*2)
+	for _, peer := range peers {
+		if peer == nil {
+			continue
+		}
+		peerIPs[peer.IP.String()] = struct{}{}
+		if includeIPv6 && peer.IPv6.IsValid() {
+			peerIPs[peer.IPv6.String()] = struct{}{}
+		}
+	}
+
+	filteredRecords := make([]nmdata.SimpleRecord, 0, len(records))
+	for _, record := range records {
+		if _, exists := peerIPs[record.RData]; exists {
+			filteredRecords = append(filteredRecords, record)
+		}
+	}
+
+	return filteredRecords
+}
+
+func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string {
+	if len(neededGroupIDs) == 0 {
+		return nil
+	}
+
+	filtered := make(map[string][]string, len(neededGroupIDs))
+	for groupID := range neededGroupIDs {
+		if users, ok := fullMap[groupID]; ok {
+			filtered[groupID] = users
+		}
+	}
+	return filtered
+}
diff --git a/shared/management/networkmap/networkmapcompute_test.go b/shared/management/networkmap/networkmapcompute_test.go
new file mode 100644
index 000000000..8c9add8c1
--- /dev/null
+++ b/shared/management/networkmap/networkmapcompute_test.go
@@ -0,0 +1,1610 @@
+package networkmap_test
+
+import (
+	"context"
+	"fmt"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	nbtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+const (
+	targetID          = "peer-target"
+	postureMinVersion = "0.30.0"
+	passingVersion    = "1.0.0"
+	failingVersion    = "0.1.0"
+)
+
+func newPeer(id string, hostNum byte) *nmdata.Peer {
+	return &nmdata.Peer{
+		ID:       id,
+		Key:      "key-" + id,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, hostNum}),
+		DNSLabel: id,
+		Meta:     nmdata.PeerSystemMeta{WtVersion: passingVersion},
+	}
+}
+
+func newNMD(peers ...*nmdata.Peer) *networkmap.NetworkMapData {
+	nmd := &networkmap.NetworkMapData{
+		Peers:           make(map[string]*nmdata.Peer),
+		Groups:          make(map[string]*nmdata.Group),
+		ValidatedPeers:  make(map[string]struct{}),
+		Network:         &nmdata.Network{Identifier: "network-1", Serial: 7},
+		AccountSettings: &nmdata.AccountSettingsInfo{},
+		DNSSettings:     &nmdata.DNSSettings{},
+	}
+	for _, p := range peers {
+		nmd.Peers[p.ID] = p
+		nmd.ValidatedPeers[p.ID] = struct{}{}
+	}
+	return nmd
+}
+
+func addGroup(nmd *networkmap.NetworkMapData, id string, peerIDs ...string) *nmdata.Group {
+	g := &nmdata.Group{ID: id, Name: id, Peers: peerIDs}
+	nmd.Groups[id] = g
+	return g
+}
+
+func newRule(sources, destinations []string) *nmdata.PolicyRule {
+	return &nmdata.PolicyRule{
+		Enabled:       true,
+		Action:        string(nbtypes.PolicyTrafficActionAccept),
+		Protocol:      string(nbtypes.PolicyRuleProtocolTCP),
+		Bidirectional: true,
+		Sources:       sources,
+		Destinations:  destinations,
+	}
+}
+
+func newPolicy(id string, rules ...*nmdata.PolicyRule) *nmdata.Policy {
+	for i, r := range rules {
+		if r.ID == "" {
+			r.ID = fmt.Sprintf("%s-rule-%d", id, i)
+		}
+		r.PolicyID = id
+	}
+	return &nmdata.Policy{ID: id, Enabled: true, Rules: rules}
+}
+
+func addVersionCheck(nmd *networkmap.NetworkMapData, id, minVersion string) {
+	if nmd.PostureChecks == nil {
+		nmd.PostureChecks = make(map[string]*nmdata.PostureChecks)
+	}
+	nmd.PostureChecks[id] = &nmdata.PostureChecks{
+		ID:     id,
+		Checks: nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: minVersion}},
+	}
+}
+
+func compute(nmd *networkmap.NetworkMapData, peerID string) *nbtypes.NetworkMapComponents {
+	return nmd.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{})
+}
+
+func peerIDSet(peers map[string]*nmdata.Peer) []string {
+	ids := make([]string, 0, len(peers))
+	for id := range peers {
+		ids = append(ids, id)
+	}
+	return ids
+}
+
+func policyIDs(policies []*nmdata.Policy) []string {
+	ids := make([]string, 0, len(policies))
+	for _, p := range policies {
+		ids = append(ids, p.ID)
+	}
+	return ids
+}
+
+func groupIDSet(groups map[string]*nmdata.Group) []string {
+	ids := make([]string, 0, len(groups))
+	for id := range groups {
+		ids = append(ids, id)
+	}
+	return ids
+}
+
+func TestGetPeerNetworkMapComponents_UnknownPeer(t *testing.T) {
+	nmd := newNMD(newPeer("peer-a", 2))
+
+	c := compute(nmd, "missing")
+
+	require.True(t, c.IsEmpty())
+	assert.Equal(t, "missing", c.PeerID)
+	assert.Same(t, nmd.Network, c.Network)
+	require.Contains(t, c.Peers, "missing")
+	assert.Nil(t, c.Peers["missing"])
+	assert.Len(t, c.Peers, 1)
+	assert.Nil(t, c.AccountSettings)
+	assert.Nil(t, c.Policies)
+	assert.False(t, c.ForceRoutingPeerDNSResolution)
+}
+
+func TestGetPeerNetworkMapComponents_UnvalidatedPeer(t *testing.T) {
+	target := newPeer(targetID, 1)
+	nmd := newNMD(target)
+	delete(nmd.ValidatedPeers, targetID)
+
+	c := compute(nmd, targetID)
+
+	require.True(t, c.IsEmpty())
+	assert.Equal(t, targetID, c.PeerID)
+	assert.Same(t, target, c.Peers[targetID])
+	assert.Len(t, c.Peers, 1)
+	assert.Nil(t, c.AccountSettings)
+	assert.Nil(t, c.Groups)
+}
+
+// The forced-DNS flag must be computed even on the empty-components early
+// exits, so an unknown or unvalidated proxy routing peer still starts its DNS
+// forwarder.
+func TestGetPeerNetworkMapComponents_EmptyComponentsKeepForcedDNSResolution(t *testing.T) {
+	build := func() *networkmap.NetworkMapData {
+		nmd := newNMD(newPeer("unval-router", 1))
+		delete(nmd.ValidatedPeers, "unval-router")
+		nmd.NetworkResources = []*nmdata.NetworkResource{
+			{ID: "res-1", NetworkID: "net-1", Type: string(nbtypes.ResourceTypeDomain), Enabled: true},
+		}
+		nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-1": {}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"ghost-router": {}, "unval-router": {}}}
+		return nmd
+	}
+
+	t.Run("unknown peer", func(t *testing.T) {
+		c := compute(build(), "ghost-router")
+		require.True(t, c.IsEmpty())
+		assert.True(t, c.ForceRoutingPeerDNSResolution)
+	})
+
+	t.Run("unvalidated peer", func(t *testing.T) {
+		c := compute(build(), "unval-router")
+		require.True(t, c.IsEmpty())
+		assert.True(t, c.ForceRoutingPeerDNSResolution)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_ForceRoutingPeerDNSResolution(t *testing.T) {
+	forced := func(mutate func(*networkmap.NetworkMapData)) bool {
+		nmd := newNMD(newPeer(targetID, 1))
+		nmd.NetworkResources = []*nmdata.NetworkResource{
+			{ID: "res-1", NetworkID: "net-1", Type: string(nbtypes.ResourceTypeDomain), Enabled: true},
+		}
+		nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-1": {}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+		if mutate != nil {
+			mutate(nmd)
+		}
+		return compute(nmd, targetID).ForceRoutingPeerDNSResolution
+	}
+
+	t.Run("router of targeted domain resource is forced", func(t *testing.T) {
+		assert.True(t, forced(nil))
+	})
+	t.Run("no proxy-targeted resources", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.ProxyTargetedDomainResourceIDs = nil
+		}))
+	})
+	t.Run("resource disabled", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.NetworkResources[0].Enabled = false
+		}))
+	})
+	t.Run("resource not a domain", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.NetworkResources[0].Type = string(nbtypes.ResourceTypeHost)
+		}))
+	})
+	t.Run("resource not targeted", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-other": {}}
+		}))
+	})
+	t.Run("peer not a router of the resource network", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"someone-else": {}}}
+		}))
+	})
+	t.Run("nil resource entry tolerated", func(t *testing.T) {
+		assert.True(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.NetworkResources = append([]*nmdata.NetworkResource{nil}, nmd.NetworkResources...)
+		}))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_CoreFieldsPassThrough(t *testing.T) {
+	target := newPeer(targetID, 1)
+	nmd := newNMD(target)
+	nmd.NetworkXIDToPublicID = map[string]string{"net-xid": "net-pub"}
+	nmd.PostureCheckXIDToPublicID = map[string]string{"pc-xid": "pc-pub"}
+
+	c := nmd.GetPeerNetworkMapComponents(targetID, nmdata.CustomZone{Domain: "acme.netbird.cloud."})
+
+	require.False(t, c.IsEmpty())
+	assert.Equal(t, targetID, c.PeerID)
+	assert.Same(t, nmd.Network, c.Network)
+	assert.Same(t, nmd.AccountSettings, c.AccountSettings)
+	assert.Same(t, nmd.DNSSettings, c.DNSSettings)
+	assert.Equal(t, "acme.netbird.cloud.", c.CustomZoneDomain)
+	assert.Equal(t, nmd.NetworkXIDToPublicID, c.NetworkXIDToPublicID)
+	assert.Equal(t, nmd.PostureCheckXIDToPublicID, c.PostureCheckXIDToPublicID)
+
+	assert.Equal(t, map[string]*nmdata.Peer{targetID: target}, c.Peers)
+	assert.Empty(t, c.Groups)
+	assert.Empty(t, c.Policies)
+	assert.Empty(t, c.Routes)
+	assert.Empty(t, c.NameServerGroups)
+	assert.Empty(t, c.NetworkResources)
+	assert.Empty(t, c.ResourcePoliciesMap)
+	assert.Empty(t, c.RoutersMap)
+	assert.Empty(t, c.RouterPeers)
+	assert.Empty(t, c.PostureFailedPeers)
+	assert.Nil(t, c.AllDNSRecords)
+	assert.Empty(t, c.AccountZones)
+	assert.Nil(t, c.GroupIDToUserIDs)
+	assert.Nil(t, c.AllowedUserIDs)
+	assert.False(t, c.ForceRoutingPeerDNSResolution)
+}
+
+func TestGetPeerNetworkMapComponents_OwnGroupsTrimmedWithoutMutatingStore(t *testing.T) {
+	target := newPeer(targetID, 1)
+	bystander := newPeer("peer-bystander", 2)
+	nmd := newNMD(target, bystander)
+	stored := addGroup(nmd, "g-mixed", targetID, bystander.ID)
+
+	c := compute(nmd, targetID)
+
+	require.Contains(t, c.Groups, "g-mixed")
+	assert.Equal(t, []string{targetID}, c.Groups["g-mixed"].Peers)
+	assert.NotSame(t, stored, c.Groups["g-mixed"])
+	assert.Equal(t, []string{targetID, bystander.ID}, stored.Peers)
+}
+
+func TestGetPeerNetworkMapComponents_PolicyRelevance(t *testing.T) {
+	t.Run("peer in sources pulls destination peers and groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		srcSibling := newPeer("peer-src-sibling", 2)
+		dst := newPeer("peer-dst", 3)
+		nmd := newNMD(target, srcSibling, dst)
+		addGroup(nmd, "g-src", targetID, srcSibling.ID)
+		addGroup(nmd, "g-dst", dst.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers),
+			"source-side siblings must not be connected")
+		assert.ElementsMatch(t, []string{"g-src", "g-dst"}, groupIDSet(c.Groups))
+		assert.Equal(t, []string{targetID}, c.Groups["g-src"].Peers)
+		assert.Equal(t, []string{dst.ID}, c.Groups["g-dst"].Peers)
+	})
+
+	t.Run("peer in destinations pulls source peers and groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers))
+		assert.ElementsMatch(t, []string{"g-src", "g-dst"}, groupIDSet(c.Groups))
+	})
+
+	t.Run("unrelated policy contributes nothing", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		a := newPeer("peer-a", 2)
+		b := newPeer("peer-b", 3)
+		nmd := newNMD(target, a, b)
+		addGroup(nmd, "g-own", targetID)
+		addGroup(nmd, "g-a", a.ID)
+		addGroup(nmd, "g-b", b.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-a"}, []string{"g-b"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.ElementsMatch(t, []string{"g-own"}, groupIDSet(c.Groups))
+	})
+
+	t.Run("disabled policy ignored", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.Enabled = false
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("disabled rule ignored", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		rule := newRule([]string{"g-src"}, []string{"g-dst"})
+		rule.Enabled = false
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("peer on both sides pulls peers from both directions", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		x := newPeer("peer-x", 2)
+		y := newPeer("peer-y", 3)
+		nmd := newNMD(target, x, y)
+		addGroup(nmd, "g-src", targetID, x.ID)
+		addGroup(nmd, "g-dst", targetID, y.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, x.ID, y.ID}, peerIDSet(c.Peers),
+			"both the source-side and destination-side counterparts must connect")
+	})
+
+	t.Run("rule referencing missing group tolerated", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		nmd := newNMD(target)
+		addGroup(nmd, "g-dst", targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-ghost"}, []string{"g-dst"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("nil policy and rule entries tolerated", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", dst.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.Rules = append([]*nmdata.PolicyRule{nil}, p.Rules...)
+		nmd.Policies = []*nmdata.Policy{nil, p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_PeerResourceRules(t *testing.T) {
+	peerResource := func(id string) nmdata.Resource {
+		return nmdata.Resource{ID: id, Type: string(nbtypes.ResourceTypePeer)}
+	}
+
+	t.Run("target as source resource", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addGroup(nmd, "g-dst", dst.ID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("target as destination resource", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		rule := newRule([]string{"g-src"}, nil)
+		rule.DestinationResource = peerResource(targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("remote peer as destination resource", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		remote := newPeer("peer-remote", 2)
+		nmd := newNMD(target, remote)
+		addGroup(nmd, "g-src", targetID)
+		rule := newRule([]string{"g-src"}, nil)
+		rule.DestinationResource = peerResource(remote.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, remote.ID}, peerIDSet(c.Peers))
+	})
+
+	// Legacy parity: directly referenced peers bypass the ValidatedPeers gate
+	// and posture checks that group-derived peers go through; the client-side
+	// Calculate shares this behavior via getPeerFromResource.
+	t.Run("unvalidated source resource peer still connects", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		unval := newPeer("peer-unval", 2)
+		nmd := newNMD(target, unval)
+		delete(nmd.ValidatedPeers, unval.ID)
+		addGroup(nmd, "g-dst", targetID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(unval.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, unval.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("source resource peer bypasses posture checks", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-dst", targetID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(failing.ID)
+		p := newPolicy("p-1", rule)
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("unrelated peer resource rule ignored", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		a := newPeer("peer-a", 2)
+		b := newPeer("peer-b", 3)
+		nmd := newNMD(target, a, b)
+		rule := newRule(nil, nil)
+		rule.SourceResource = peerResource(a.ID)
+		rule.DestinationResource = peerResource(b.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+}
+
+// A destination list containing a group named "All" short-circuits peer
+// expansion to that group alone, dropping peers accumulated from earlier
+// groups. Groups themselves are still all shipped. Mirrors legacy behavior
+// that the wire encoding depends on (see
+// TestEnvelopeRoundTrip_AllGroupShortCircuitParity).
+func TestGetPeerNetworkMapComponents_AllGroupShortCircuit(t *testing.T) {
+	target := newPeer(targetID, 1)
+	first := newPeer("peer-first", 2)
+	allMember := newPeer("peer-all-member", 3)
+	nmd := newNMD(target, first, allMember)
+	addGroup(nmd, "g-src", targetID)
+	addGroup(nmd, "g-first", first.ID)
+	nmd.Groups["g-all"] = &nmdata.Group{ID: "g-all", Name: nmdata.GroupAllName, Peers: []string{targetID, allMember.ID}}
+	nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-first", "g-all"}))}
+
+	c := compute(nmd, targetID)
+
+	assert.ElementsMatch(t, []string{targetID, allMember.ID}, peerIDSet(c.Peers),
+		"peers from groups before the All group must be dropped by the short-circuit")
+	assert.ElementsMatch(t, []string{"g-src", "g-first", "g-all"}, groupIDSet(c.Groups))
+	assert.Empty(t, c.Groups["g-first"].Peers)
+}
+
+func TestGetPeerNetworkMapComponents_UnvalidatedPolicyPeersExcluded(t *testing.T) {
+	target := newPeer(targetID, 1)
+	srcOK := newPeer("peer-src-ok", 2)
+	srcUnval := newPeer("peer-src-unval", 3)
+	nmd := newNMD(target, srcOK, srcUnval)
+	delete(nmd.ValidatedPeers, srcUnval.ID)
+	addGroup(nmd, "g-src", srcOK.ID, srcUnval.ID, "peer-deleted")
+	addGroup(nmd, "g-dst", targetID)
+	nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+
+	c := compute(nmd, targetID)
+
+	assert.ElementsMatch(t, []string{targetID, srcOK.ID}, peerIDSet(c.Peers),
+		"unvalidated and dangling group members must not connect")
+	assert.Equal(t, []string{srcOK.ID}, c.Groups["g-src"].Peers)
+}
+
+// Multi-group rules take the union path of getPeersFromGroups (no All-group
+// short-circuit); validation and source posture checks apply per member.
+func TestGetPeerNetworkMapComponents_MultiGroupSources(t *testing.T) {
+	target := newPeer(targetID, 1)
+	dup := newPeer("peer-dup", 2)
+	unval := newPeer("peer-unval", 3)
+	failing := newPeer("peer-failing", 4)
+	failing.Meta.WtVersion = failingVersion
+	solo := newPeer("peer-solo", 5)
+	nmd := newNMD(target, dup, unval, failing, solo)
+	delete(nmd.ValidatedPeers, unval.ID)
+	addVersionCheck(nmd, "pc-1", postureMinVersion)
+	addGroup(nmd, "g-1", targetID, dup.ID, unval.ID, "peer-deleted")
+	addGroup(nmd, "g-2", dup.ID, failing.ID, solo.ID)
+	addGroup(nmd, "g-tgt", targetID)
+	p := newPolicy("p-1", newRule([]string{"g-1", "g-2"}, []string{"g-tgt"}))
+	p.SourcePostureChecks = []string{"pc-1"}
+	nmd.Policies = []*nmdata.Policy{p}
+
+	c := compute(nmd, targetID)
+
+	assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+	assert.ElementsMatch(t, []string{targetID, dup.ID, solo.ID}, peerIDSet(c.Peers))
+	assert.Empty(t, c.PostureFailedPeers,
+		"failing is not otherwise connected, so its failure record is pruned")
+}
+
+func TestGetPeerNetworkMapComponents_PostureChecks(t *testing.T) {
+	t.Run("failing source peer excluded without orphan failure record", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", failing.ID)
+		addGroup(nmd, "g-dst", targetID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers,
+			"failure records for peers absent from the map must be pruned")
+	})
+
+	t.Run("failure recorded when peer is connected via another policy", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", failing.ID)
+		addGroup(nmd, "g-dst", targetID)
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-1"}
+		open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"}))
+		nmd.Policies = []*nmdata.Policy{checked, open}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {failing.ID: {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("destination peers bypass source posture checks", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", failing.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("target failing its own source check drops the policy", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		target.Meta.WtVersion = failingVersion
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", dst.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("failure keyed by the first failing check", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-pass", postureMinVersion)
+		addVersionCheck(nmd, "pc-fail", "2.0.0")
+		addGroup(nmd, "g-src", failing.ID)
+		addGroup(nmd, "g-dst", targetID)
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-pass", "pc-fail"}
+		open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"}))
+		nmd.Policies = []*nmdata.Policy{checked, open}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, map[string]map[string]struct{}{"pc-fail": {failing.ID: {}}}, c.PostureFailedPeers,
+			"the record must be keyed by the failing check, not the first listed")
+	})
+
+	t.Run("unknown posture check id passes everyone", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-ghost"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_Routes(t *testing.T) {
+	t.Run("owned route relevant even when disabled", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		dist := newPeer("peer-dist", 2)
+		nmd := newNMD(target, dist)
+		addGroup(nmd, "g-dist", dist.ID)
+		addGroup(nmd, "g-acl")
+		r := &nmdata.Route{ID: "r-1", Peer: targetID, Enabled: false, Groups: []string{"g-dist"}, AccessControlGroups: []string{"g-acl"}}
+		nmd.Routes = []*nmdata.Route{r}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.Same(t, r, c.Routes[0])
+		assert.Contains(t, c.Groups, "g-dist")
+		assert.NotContains(t, c.Groups, "g-acl",
+			"access control groups of a disabled route must not be collected")
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers),
+			"distribution group members are not connected by the route itself")
+	})
+
+	t.Run("peer-group route disabled still ships and connects HA members", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		ha := newPeer("peer-ha", 2)
+		haUnval := newPeer("peer-ha-unval", 3)
+		nmd := newNMD(target, ha, haUnval)
+		delete(nmd.ValidatedPeers, haUnval.ID)
+		addGroup(nmd, "g-ha", targetID, ha.ID, haUnval.ID)
+		r := &nmdata.Route{ID: "r-1", PeerGroups: []string{"g-ha", "g-ghost"}, Enabled: false}
+		nmd.Routes = []*nmdata.Route{r}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID, ha.ID}, peerIDSet(c.Peers))
+		assert.Equal(t, []string{targetID, ha.ID}, c.Groups["g-ha"].Peers)
+	})
+
+	t.Run("route consumer connects HA routing peers from peer groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router1 := newPeer("peer-router-1", 2)
+		router2 := newPeer("peer-router-2", 3)
+		routerUnval := newPeer("peer-router-unval", 4)
+		nmd := newNMD(target, router1, router2, routerUnval)
+		delete(nmd.ValidatedPeers, routerUnval.ID)
+		addGroup(nmd, "g-ha", router1.ID, router2.ID, routerUnval.ID)
+		addGroup(nmd, "g-dist", targetID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", PeerGroups: []string{"g-ha"}, Groups: []string{"g-dist"}, Enabled: true}}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID, router1.ID, router2.ID}, peerIDSet(c.Peers),
+			"the consumer must connect to every validated HA router")
+		assert.Equal(t, []string{router1.ID, router2.ID}, c.Groups["g-ha"].Peers)
+	})
+
+	t.Run("distribution route connects routing peer", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		addGroup(nmd, "g-dist", targetID)
+		r := &nmdata.Route{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}
+		nmd.Routes = []*nmdata.Route{r}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID, router.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("disabled distribution route not relevant", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		addGroup(nmd, "g-dist", targetID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: false, Groups: []string{"g-dist"}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Routes)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("unvalidated routing peer excluded but route ships", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		delete(nmd.ValidatedPeers, router.ID)
+		addGroup(nmd, "g-dist", targetID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("nil and unrelated routes skipped", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		other := newPeer("peer-other", 2)
+		nmd := newNMD(target, other)
+		addGroup(nmd, "g-dist", targetID)
+		addGroup(nmd, "g-foreign", other.ID)
+		owned := &nmdata.Route{ID: "r-owned", Peer: targetID, Enabled: true}
+		nmd.Routes = []*nmdata.Route{nil, {ID: "r-foreign", Peer: other.ID, Enabled: true, Groups: []string{"g-foreign"}}, owned}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.Same(t, owned, c.Routes[0])
+	})
+}
+
+// A policy whose destinations hit an enabled route's access control groups is
+// shipped so the routing peer can build route firewall rules, but its peers
+// are not connected through this bridge.
+func TestGetPeerNetworkMapComponents_RouteAccessControlBridging(t *testing.T) {
+	t.Run("policy targeting route ACG becomes relevant", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		remote := newPeer("peer-remote", 3)
+		nmd := newNMD(target, router, remote)
+		addGroup(nmd, "g-dist", targetID)
+		addGroup(nmd, "g-acl")
+		addGroup(nmd, "g-remote", remote.ID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}, AccessControlGroups: []string{"g-acl"}}}
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-remote"}, []string{"g-acl"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-acl"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{"g-dist", "g-acl", "g-remote"}, groupIDSet(c.Groups))
+		assert.ElementsMatch(t, []string{targetID, router.ID}, peerIDSet(c.Peers),
+			"the bridged policy's source peers must not be connected")
+	})
+
+	t.Run("disabled route does not bridge its ACG policies", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		remote := newPeer("peer-remote", 2)
+		nmd := newNMD(target, remote)
+		addGroup(nmd, "g-acl")
+		addGroup(nmd, "g-remote", remote.ID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: targetID, Enabled: false, AccessControlGroups: []string{"g-acl"}}}
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-remote"}, []string{"g-acl"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_SSHRequirements(t *testing.T) {
+	allowedUsers := map[string]struct{}{"user-1": {}, "user-2": {}}
+	groupUsers := map[string][]string{"g-auth": {"user-a"}, "g-other": {"user-b"}}
+
+	cases := []struct {
+		name          string
+		mutateRule    func(*nmdata.PolicyRule)
+		sshEnabled    bool
+		targetInSrc   bool
+		wantAllowed   bool
+		wantGroupsMap map[string][]string
+	}{
+		{
+			name: "netbird-ssh with authorized groups",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+				r.AuthorizedGroups = map[string][]string{"g-auth": nil}
+			},
+			wantGroupsMap: map[string][]string{"g-auth": {"user-a"}},
+		},
+		{
+			name: "netbird-ssh with authorized user",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+				r.AuthorizedUser = "root"
+			},
+		},
+		{
+			name: "netbird-ssh default needs allowed users",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+			},
+			wantAllowed: true,
+		},
+		{
+			name:        "legacy all-protocol with SSH enabled",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.Protocol = string(nbtypes.PolicyRuleProtocolALL) },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:       "legacy all-protocol with SSH disabled",
+			mutateRule: func(r *nmdata.PolicyRule) { r.Protocol = string(nbtypes.PolicyRuleProtocolALL) },
+		},
+		{
+			name:        "tcp port 22 with SSH enabled",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.Ports = []string{"22"} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:        "tcp port range covering 22",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.PortRanges = []nmdata.RulePortRange{{Start: 20, End: 30}} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:        "tcp native ssh port 22022",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.Ports = []string{"22022"} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:        "tcp port range covering only native ssh port",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.PortRanges = []nmdata.RulePortRange{{Start: 22000, End: 23000}} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:       "tcp unrelated port",
+			mutateRule: func(r *nmdata.PolicyRule) { r.Ports = []string{"443"} },
+			sshEnabled: true,
+		},
+		{
+			name: "netbird-ssh only counts on the destination side",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+			},
+			targetInSrc: true,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			target := newPeer(targetID, 1)
+			target.SSHEnabled = tc.sshEnabled
+			admin := newPeer("peer-admin", 2)
+			nmd := newNMD(target, admin)
+			nmd.AllowedUserIDs = allowedUsers
+			nmd.GroupIDToUserIDs = groupUsers
+			addGroup(nmd, "g-adm", admin.ID)
+			addGroup(nmd, "g-tgt", targetID)
+			rule := newRule([]string{"g-adm"}, []string{"g-tgt"})
+			if tc.targetInSrc {
+				rule = newRule([]string{"g-tgt"}, []string{"g-adm"})
+			}
+			tc.mutateRule(rule)
+			nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+			c := compute(nmd, targetID)
+
+			if tc.wantAllowed {
+				assert.Equal(t, allowedUsers, c.AllowedUserIDs)
+			} else {
+				assert.Nil(t, c.AllowedUserIDs)
+			}
+			assert.Equal(t, tc.wantGroupsMap, c.GroupIDToUserIDs)
+		})
+	}
+}
+
+func TestGetPeerNetworkMapComponents_DNSRecordFiltering(t *testing.T) {
+	record := func(name, rdata string) nmdata.SimpleRecord {
+		return nmdata.SimpleRecord{Name: name, Type: 1, Class: "IN", TTL: 300, RData: rdata}
+	}
+
+	build := func(ipv6Target bool) (*networkmap.NetworkMapData, nmdata.CustomZone) {
+		target := newPeer(targetID, 1)
+		if ipv6Target {
+			target.IPv6 = netip.MustParseAddr("fd00::1")
+			target.Meta.Capabilities = []int32{nmdata.PeerCapabilityIPv6Overlay}
+		}
+		buddy := newPeer("peer-buddy", 2)
+		buddy.IPv6 = netip.MustParseAddr("fd00::2")
+		stranger := newPeer("peer-stranger", 3)
+		nmd := newNMD(target, buddy, stranger)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", buddy.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+		zone := nmdata.CustomZone{
+			Domain: "acme.netbird.cloud.",
+			Records: []nmdata.SimpleRecord{
+				record(targetID, "100.64.0.1"),
+				record("peer-buddy", "100.64.0.2"),
+				record("peer-stranger", "100.64.0.3"),
+				record("outsider", "9.9.9.9"),
+				record("peer-buddy-v6", "fd00::2"),
+			},
+		}
+		return nmd, zone
+	}
+
+	t.Run("records limited to relevant peers, IPv6 dropped without capability", func(t *testing.T) {
+		nmd, zone := build(false)
+
+		c := nmd.GetPeerNetworkMapComponents(targetID, zone)
+
+		assert.Equal(t, "acme.netbird.cloud.", c.CustomZoneDomain)
+		assert.Equal(t, []nmdata.SimpleRecord{
+			record(targetID, "100.64.0.1"),
+			record("peer-buddy", "100.64.0.2"),
+		}, c.AllDNSRecords)
+	})
+
+	t.Run("IPv6 records of relevant peers kept for capable target", func(t *testing.T) {
+		nmd, zone := build(true)
+
+		c := nmd.GetPeerNetworkMapComponents(targetID, zone)
+
+		assert.Equal(t, []nmdata.SimpleRecord{
+			record(targetID, "100.64.0.1"),
+			record("peer-buddy", "100.64.0.2"),
+			record("peer-buddy-v6", "fd00::2"),
+		}, c.AllDNSRecords)
+	})
+
+	t.Run("no records yields nil", func(t *testing.T) {
+		nmd, _ := build(false)
+
+		c := nmd.GetPeerNetworkMapComponents(targetID, nmdata.CustomZone{Domain: "acme.netbird.cloud."})
+
+		assert.Nil(t, c.AllDNSRecords)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_AccountZones(t *testing.T) {
+	rec := func(name string) nmdata.SimpleRecord {
+		return nmdata.SimpleRecord{Name: name, Type: 1, Class: "IN", RData: "100.64.0.9"}
+	}
+
+	t.Run("applied and private service zones for peer groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		nmd := newNMD(target)
+		addGroup(nmd, "g-a", targetID)
+		appliedZone := nmdata.CustomZone{Domain: "zone-one.example.com.", Records: []nmdata.SimpleRecord{rec("z1")}}
+		nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{
+			{DistributionGroups: []string{"g-a"}, Zone: appliedZone},
+			{DistributionGroups: []string{"g-x"}, Zone: nmdata.CustomZone{Domain: "zone-two.example.com."}},
+		}
+		nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", SearchDomainDisabled: true, NonAuthoritative: true, Records: []nmdata.SimpleRecord{rec("svc-1")}}},
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{rec("svc-2")}}},
+			{AccessGroups: []string{"g-x"}, Zone: nmdata.CustomZone{Domain: "other.example.com", Records: []nmdata.SimpleRecord{rec("other")}}},
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "empty.example.com"}},
+		}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.AccountZones, 2)
+		assert.Equal(t, appliedZone, c.AccountZones[0])
+		assert.Equal(t, nmdata.CustomZone{
+			Domain:               "svc.example.com",
+			SearchDomainDisabled: true,
+			NonAuthoritative:     true,
+			Records:              []nmdata.SimpleRecord{rec("svc-1"), rec("svc-2")},
+		}, c.AccountZones[1], "same-apex private service candidates must merge, flags from the first")
+	})
+
+	t.Run("groupless peer receives no zones", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		nmd := newNMD(target)
+		nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{
+			{DistributionGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "zone-one.example.com."}},
+		}
+		nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{rec("svc")}}},
+		}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.AccountZones)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_NameServerGroups(t *testing.T) {
+	target := newPeer(targetID, 1)
+	other := newPeer("peer-other", 2)
+	nmd := newNMD(target, other)
+	addGroup(nmd, "g-own", targetID)
+	addGroup(nmd, "g-dst", other.ID)
+	addGroup(nmd, "g-foreign")
+	nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-own"}, []string{"g-dst"}))}
+	nsOwn := &nmdata.NameServerGroup{ID: "ns-own", Enabled: true, Groups: []string{"g-own"}}
+	nsDst := &nmdata.NameServerGroup{ID: "ns-dst", Enabled: true, Groups: []string{"g-dst"}}
+	nsDisabled := &nmdata.NameServerGroup{ID: "ns-disabled", Enabled: false, Groups: []string{"g-own"}}
+	nsForeign := &nmdata.NameServerGroup{ID: "ns-foreign", Enabled: true, Groups: []string{"g-foreign"}}
+	nsBoth := &nmdata.NameServerGroup{ID: "ns-both", Enabled: true, Groups: []string{"g-own", "g-dst"}}
+	nmd.NameServerGroups = []*nmdata.NameServerGroup{nsOwn, nil, nsDst, nsDisabled, nsForeign, nsBoth}
+
+	c := compute(nmd, targetID)
+
+	assert.Equal(t, []*nmdata.NameServerGroup{nsOwn, nsDst, nsBoth}, c.NameServerGroups,
+		"nameserver groups attach to any relevant group and ship once even when several groups match")
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_SourceSide(t *testing.T) {
+	target := newPeer(targetID, 1)
+	routerOK := newPeer("peer-router-ok", 2)
+	routerUnval := newPeer("peer-router-unval", 3)
+	nmd := newNMD(target, routerOK, routerUnval)
+	delete(nmd.ValidatedPeers, routerUnval.ID)
+	addGroup(nmd, "g-clients", targetID)
+	addGroup(nmd, "g-resource")
+	res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+	nmd.NetworkResources = []*nmdata.NetworkResource{res}
+	rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+	nmd.Policies = []*nmdata.Policy{rp}
+	nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+	routers := map[string]*nmdata.NetworkRouter{
+		routerOK.ID:    {Metric: 100},
+		routerUnval.ID: {Metric: 200},
+	}
+	nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": routers}
+
+	c := compute(nmd, targetID)
+
+	assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+	assert.Equal(t, map[string][]*nmdata.Policy{"res-1": {rp}}, c.ResourcePoliciesMap)
+	assert.Equal(t, map[string]map[string]*nmdata.NetworkRouter{"net-1": routers}, c.RoutersMap)
+	assert.ElementsMatch(t, []string{routerOK.ID}, peerIDSet(c.RouterPeers),
+		"an unvalidated routing peer is withheld from RouterPeers too, since the envelope encoder "+
+			"indexes that map into the wire peer table and the client restores every entry from it")
+	assert.ElementsMatch(t, []string{targetID, routerOK.ID}, peerIDSet(c.Peers),
+		"only validated routing peers are connected")
+	assert.ElementsMatch(t, []string{"g-clients", "g-resource"}, groupIDSet(c.Groups))
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_RouterSide(t *testing.T) {
+	t.Run("posture-valid validated source peers connected, failures recorded", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		clientOK := newPeer("peer-client-ok", 2)
+		clientUnval := newPeer("peer-client-unval", 3)
+		clientFail := newPeer("peer-client-fail", 4)
+		clientFail.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, clientOK, clientUnval, clientFail)
+		delete(nmd.ValidatedPeers, clientUnval.ID)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-clients", clientOK.ID, clientUnval.ID, clientFail.ID)
+		addGroup(nmd, "g-resource")
+		addGroup(nmd, "g-tgt", targetID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+		rp.SourcePostureChecks = []string{"pc-1"}
+		acl := newPolicy("p-acl", newRule([]string{"g-clients"}, []string{"g-tgt"}))
+		nmd.Policies = []*nmdata.Policy{acl}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {Metric: 100}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.RouterPeers))
+		assert.ElementsMatch(t, []string{targetID, clientOK.ID, clientFail.ID}, peerIDSet(c.Peers),
+			"clientFail connects via the open ACL policy, clientUnval never connects")
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {clientFail.ID: {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("peer source resource collects exactly that peer", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		client := newPeer("peer-client", 2)
+		other := newPeer("peer-other", 3)
+		nmd := newNMD(target, client, other)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rule := newRule(nil, nil)
+		rule.SourceResource = nmdata.Resource{ID: client.ID, Type: string(nbtypes.ResourceTypePeer)}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {newPolicy("rp-1", rule)}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+		assert.ElementsMatch(t, []string{targetID, client.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("multiple source groups unioned, missing group tolerated", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		c1 := newPeer("peer-c1", 2)
+		c2 := newPeer("peer-c2", 3)
+		nmd := newNMD(target, c1, c2)
+		addGroup(nmd, "g-c1", c1.ID)
+		addGroup(nmd, "g-c2", c2.ID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{
+			"res-1": {newPolicy("rp-1", newRule([]string{"g-c1", "g-c2", "g-ghost"}, nil))},
+		}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, c1.ID, c2.ID}, peerIDSet(c.Peers))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_PeerResourceSource(t *testing.T) {
+	build := func(sourcePeerID string) *networkmap.NetworkMapData {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		other := newPeer("peer-other", 3)
+		nmd := newNMD(target, router, other)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rule := newRule(nil, nil)
+		rule.SourceResource = nmdata.Resource{ID: sourcePeerID, Type: string(nbtypes.ResourceTypePeer)}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {newPolicy("rp-1", rule)}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"peer-router": {}}}
+		return nmd
+	}
+
+	t.Run("target named as source resource gains access", func(t *testing.T) {
+		c := compute(build(targetID), targetID)
+
+		assert.Len(t, c.NetworkResources, 1)
+		assert.ElementsMatch(t, []string{targetID, "peer-router"}, peerIDSet(c.Peers))
+	})
+
+	t.Run("other peer named as source resource denies target", func(t *testing.T) {
+		c := compute(build("peer-other"), targetID)
+
+		assert.Empty(t, c.NetworkResources)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_Gating(t *testing.T) {
+	build := func() (*networkmap.NetworkMapData, *nmdata.NetworkResource, *nmdata.Policy) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		addGroup(nmd, "g-clients", targetID)
+		addGroup(nmd, "g-resource")
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+		nmd.Policies = []*nmdata.Policy{rp}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}}
+		return nmd, res, rp
+	}
+
+	assertResourceSkipped := func(t *testing.T, c *nbtypes.NetworkMapComponents) {
+		t.Helper()
+		assert.Empty(t, c.NetworkResources)
+		assert.Empty(t, c.RoutersMap)
+		assert.Empty(t, c.RouterPeers)
+		assert.Empty(t, c.ResourcePoliciesMap)
+	}
+
+	t.Run("baseline grants access", func(t *testing.T) {
+		nmd, res, _ := build()
+		c := compute(nmd, targetID)
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+	})
+
+	t.Run("disabled resource skipped", func(t *testing.T) {
+		nmd, res, _ := build()
+		res.Enabled = false
+		assertResourceSkipped(t, compute(nmd, targetID))
+	})
+
+	t.Run("resource without policies skipped", func(t *testing.T) {
+		nmd, _, _ := build()
+		nmd.ResourcePolicies = nil
+		assertResourceSkipped(t, compute(nmd, targetID))
+	})
+
+	t.Run("peer neither router nor in sources skipped", func(t *testing.T) {
+		nmd, _, _ := build()
+		nmd.Groups["g-clients"].Peers = []string{"peer-router"}
+		c := compute(nmd, targetID)
+		assertResourceSkipped(t, c)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("nil and rule-less resource policy entries tolerated", func(t *testing.T) {
+		nmd, res, rp := build()
+		nmd.NetworkResources = append([]*nmdata.NetworkResource{nil}, nmd.NetworkResources...)
+		rp.Rules = append(rp.Rules, nil)
+		nmd.ResourcePolicies["res-1"] = append([]*nmdata.Policy{nil, {ID: "rp-empty", Enabled: true}}, nmd.ResourcePolicies["res-1"]...)
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources,
+			"poisoned sibling entries must not prevent the valid policy from granting access")
+		assert.NotPanics(t, func() { c.Calculate(context.Background()) },
+			"the downstream network map calculation must survive the poisoned components")
+	})
+
+	t.Run("granting policy without routers still ships the resource", func(t *testing.T) {
+		nmd, res, rp := build()
+		nmd.Routers = nil
+		c := compute(nmd, targetID)
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+		assert.Equal(t, map[string][]*nmdata.Policy{"res-1": {rp}}, c.ResourcePoliciesMap)
+		assert.Contains(t, c.RoutersMap, "net-1")
+		assert.Empty(t, c.RoutersMap["net-1"])
+		assert.Empty(t, c.RouterPeers)
+	})
+
+	t.Run("target failing resource policy posture check skipped", func(t *testing.T) {
+		nmd, _, rp := build()
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		rp.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = nil
+		nmd.Peers[targetID].Meta.WtVersion = failingVersion
+		c := compute(nmd, targetID)
+		assertResourceSkipped(t, c)
+		assert.Empty(t, c.PostureFailedPeers)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+}
+
+// Legacy parity: resource-policy access consults only Rules[0] for peer-type
+// sources, while group sources union across all rules via SourceGroups.
+func TestGetPeerNetworkMapComponents_MultiRulePolicies(t *testing.T) {
+	t.Run("policy matching via multiple rules ships once", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		a := newPeer("peer-a", 2)
+		b := newPeer("peer-b", 3)
+		nmd := newNMD(target, a, b)
+		addGroup(nmd, "g-tgt", targetID)
+		addGroup(nmd, "g-a", a.ID)
+		addGroup(nmd, "g-b", b.ID)
+		p := newPolicy("p-1",
+			newRule([]string{"g-tgt"}, []string{"g-a"}),
+			newRule([]string{"g-tgt"}, []string{"g-b"}))
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, a.ID, b.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("resource access consults only the first rule's peer source", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		other := newPeer("peer-other", 2)
+		router := newPeer("peer-router", 3)
+		nmd := newNMD(target, other, router)
+		addGroup(nmd, "g-other", other.ID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		second := newRule(nil, nil)
+		second.SourceResource = nmdata.Resource{ID: targetID, Type: string(nbtypes.ResourceTypePeer)}
+		rp := newPolicy("rp-1", newRule([]string{"g-other"}, nil), second)
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.NetworkResources,
+			"a second rule naming the target as peer source must not grant resource access")
+	})
+
+	t.Run("router-side source collection consults only the first rule's peer source", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		x := newPeer("peer-x", 2)
+		y := newPeer("peer-y", 3)
+		nmd := newNMD(target, x, y)
+		addGroup(nmd, "g-y", y.ID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		first := newRule(nil, nil)
+		first.SourceResource = nmdata.Resource{ID: x.ID, Type: string(nbtypes.ResourceTypePeer)}
+		rp := newPolicy("rp-1", first, newRule([]string{"g-y"}, nil))
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, x.ID}, peerIDSet(c.Peers),
+			"second-rule group sources are not collected when the first rule names a peer")
+	})
+}
+
+// Characterization of legacy parity: once one resource policy grants the peer
+// access, the source peers of the resource's subsequent policies are collected
+// as if the peer were a router.
+func TestGetPeerNetworkMapComponents_NetworkResources_LaterPoliciesContributeSourcePeers(t *testing.T) {
+	target := newPeer(targetID, 1)
+	otherSrc := newPeer("peer-other-src", 2)
+	router := newPeer("peer-router", 3)
+	nmd := newNMD(target, otherSrc, router)
+	addGroup(nmd, "g-a", targetID)
+	addGroup(nmd, "g-b", otherSrc.ID)
+	addGroup(nmd, "g-resource")
+	res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+	nmd.NetworkResources = []*nmdata.NetworkResource{res}
+	rpA := newPolicy("rp-a", newRule([]string{"g-a"}, []string{"g-resource"}))
+	rpB := newPolicy("rp-b", newRule([]string{"g-b"}, []string{"g-resource"}))
+	nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rpA, rpB}}
+	nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}}
+
+	c := compute(nmd, targetID)
+
+	assert.Contains(t, c.Peers, otherSrc.ID)
+}
+
+func TestGetPeerNetworkMapComponents_StoreImmutableAndDeterministic(t *testing.T) {
+	zone := nmdata.CustomZone{
+		Domain:  "acme.netbird.cloud.",
+		Records: []nmdata.SimpleRecord{{Name: "peer-src", Type: 1, Class: "IN", TTL: 300, RData: "100.64.0.2"}},
+	}
+	build := func() *networkmap.NetworkMapData {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		failing := newPeer("peer-failing", 3)
+		failing.Meta.WtVersion = failingVersion
+		router := newPeer("peer-router", 4)
+		resRouter := newPeer("peer-res-router", 5)
+		nmd := newNMD(target, src, failing, router, resRouter)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", src.ID, failing.ID)
+		addGroup(nmd, "g-dst", targetID, src.ID)
+		addGroup(nmd, "g-dist", targetID, src.ID)
+		addGroup(nmd, "g-auth", src.ID)
+		addGroup(nmd, "g-clients", targetID)
+		addGroup(nmd, "g-resource")
+		nmd.AllowedUserIDs = map[string]struct{}{"user-1": {}}
+		nmd.GroupIDToUserIDs = map[string][]string{"g-auth": {"user-a"}}
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-1"}
+		open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"}))
+		sshAuth := newRule([]string{"g-src"}, []string{"g-dst"})
+		sshAuth.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+		sshAuth.AuthorizedGroups = map[string][]string{"g-auth": nil}
+		sshPlain := newRule([]string{"g-src"}, []string{"g-dst"})
+		sshPlain.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+		rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+		nmd.Policies = []*nmdata.Policy{checked, open, newPolicy("p-ssh", sshAuth, sshPlain), rp}
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}}
+		nmd.NetworkResources = []*nmdata.NetworkResource{{ID: "res-1", NetworkID: "net-1", Enabled: true}}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {resRouter.ID: {Metric: 100}}}
+		nmd.NameServerGroups = []*nmdata.NameServerGroup{{ID: "ns-1", Enabled: true, Groups: []string{"g-dst"}}}
+		nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{
+			{DistributionGroups: []string{"g-dst"}, Zone: nmdata.CustomZone{Domain: "zone.example.com.", Records: []nmdata.SimpleRecord{{Name: "z", Type: 1, RData: "100.64.0.9"}}}},
+		}
+		nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{
+			{AccessGroups: []string{"g-dst"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{{Name: "s", Type: 1, RData: "100.64.0.8"}}}},
+		}
+		return nmd
+	}
+
+	nmd := build()
+	groupSnapshots := make(map[string][]string, len(nmd.Groups))
+	for id, g := range nmd.Groups {
+		groupSnapshots[id] = append([]string(nil), g.Peers...)
+	}
+
+	first := nmd.GetPeerNetworkMapComponents(targetID, zone)
+	_ = nmd.GetPeerNetworkMapComponents("peer-src", zone)
+	second := nmd.GetPeerNetworkMapComponents(targetID, zone)
+
+	for id, g := range nmd.Groups {
+		assert.Equal(t, groupSnapshots[id], g.Peers, "group %s mutated in the store", id)
+	}
+
+	for name, field := range map[string]any{
+		"Peers":               first.Peers,
+		"PostureFailedPeers":  first.PostureFailedPeers,
+		"RoutersMap":          first.RoutersMap,
+		"RouterPeers":         first.RouterPeers,
+		"NetworkResources":    first.NetworkResources,
+		"NameServerGroups":    first.NameServerGroups,
+		"AccountZones":        first.AccountZones,
+		"AllDNSRecords":       first.AllDNSRecords,
+		"AllowedUserIDs":      first.AllowedUserIDs,
+		"GroupIDToUserIDs":    first.GroupIDToUserIDs,
+		"ResourcePoliciesMap": first.ResourcePoliciesMap,
+	} {
+		require.NotEmpty(t, field, "fixture must populate %s or the determinism check is vacuous", name)
+	}
+
+	assert.Equal(t, first.Peers, second.Peers)
+	assert.Equal(t, first.Groups, second.Groups)
+	assert.Equal(t, first.Policies, second.Policies)
+	assert.Equal(t, first.Routes, second.Routes)
+	assert.Equal(t, first.PostureFailedPeers, second.PostureFailedPeers)
+	assert.Equal(t, first.ResourcePoliciesMap, second.ResourcePoliciesMap)
+	assert.Equal(t, first.RoutersMap, second.RoutersMap)
+	assert.Equal(t, first.RouterPeers, second.RouterPeers)
+	assert.Equal(t, first.NetworkResources, second.NetworkResources)
+	assert.Equal(t, first.NameServerGroups, second.NameServerGroups)
+	assert.Equal(t, first.AccountZones, second.AccountZones)
+	assert.Equal(t, first.AllDNSRecords, second.AllDNSRecords)
+	assert.Equal(t, first.AllowedUserIDs, second.AllowedUserIDs)
+	assert.Equal(t, first.GroupIDToUserIDs, second.GroupIDToUserIDs)
+}
+
+func TestPrecomputePostureValidation(t *testing.T) {
+	newFixture := func() *networkmap.NetworkMapData {
+		target := newPeer(targetID, 1)
+		srcPass := newPeer("peer-src-pass", 2)
+		srcFail := newPeer("peer-src-fail", 3)
+		srcFail.Meta.WtVersion = failingVersion
+		other := newPeer("peer-other", 4)
+		other.Meta.WtVersion = failingVersion
+
+		nmd := newNMD(target, srcPass, srcFail, other)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", srcPass.ID, srcFail.ID)
+		addGroup(nmd, "g-dst", targetID)
+		addGroup(nmd, "g-open", srcPass.ID, srcFail.ID, other.ID)
+
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-1"}
+		open := newPolicy("p-open", newRule([]string{"g-open"}, []string{"g-dst"}))
+		disabled := newPolicy("p-disabled", newRule([]string{"g-open"}, []string{"g-dst"}))
+		disabled.Enabled = false
+		disabled.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{checked, open, disabled}
+
+		return nmd
+	}
+
+	type snapshot struct {
+		peers              []string
+		postureFailedPeers map[string]map[string]struct{}
+	}
+	snapshotAll := func(nmd *networkmap.NetworkMapData) map[string]snapshot {
+		out := make(map[string]snapshot, len(nmd.Peers))
+		for peerID := range nmd.Peers {
+			c := compute(nmd, peerID)
+			out[peerID] = snapshot{peers: peerIDSet(c.Peers), postureFailedPeers: c.PostureFailedPeers}
+		}
+		return out
+	}
+
+	t.Run("memoized results match direct evaluation", func(t *testing.T) {
+		nmd := newFixture()
+		direct := snapshotAll(nmd)
+
+		nmd.PrecomputePostureValidation()
+		memoized := snapshotAll(nmd)
+
+		require.Len(t, memoized, len(direct))
+		for peerID, want := range direct {
+			assert.ElementsMatch(t, want.peers, memoized[peerID].peers, "visible peers changed for %s", peerID)
+			assert.Equal(t, want.postureFailedPeers, memoized[peerID].postureFailedPeers, "posture failures changed for %s", peerID)
+		}
+	})
+
+	t.Run("only source peers of enabled checked policies are evaluated", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PrecomputePostureValidation()
+
+		assert.Equal(t, map[string]map[string]bool{
+			"pc-1": {"peer-src-pass": true, "peer-src-fail": false},
+		}, nmd.PostureValidation)
+	})
+
+	t.Run("peer source resources are evaluated", func(t *testing.T) {
+		nmd := newFixture()
+		resourcePolicy := newPolicy("p-resource", newRule(nil, []string{"g-dst"}))
+		resourcePolicy.Rules[0].SourceResource = nmdata.Resource{ID: "peer-other", Type: string(nbtypes.ResourceTypePeer)}
+		resourcePolicy.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = append(nmd.Policies, resourcePolicy)
+
+		nmd.PrecomputePostureValidation()
+
+		assert.Equal(t, map[string]bool{"peer-src-pass": true, "peer-src-fail": false, "peer-other": false},
+			nmd.PostureValidation["pc-1"])
+	})
+
+	t.Run("memoized result wins over direct evaluation", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PostureValidation = map[string]map[string]bool{
+			"pc-1": {"peer-src-pass": false, "peer-src-fail": true},
+		}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {"peer-src-pass": {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("no posture checks clears the memo", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PrecomputePostureValidation()
+		require.NotEmpty(t, nmd.PostureValidation)
+
+		nmd.PostureChecks = nil
+		nmd.PrecomputePostureValidation()
+
+		assert.Nil(t, nmd.PostureValidation)
+	})
+
+	t.Run("unresolvable check id memoized as passing", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.Policies[0].SourcePostureChecks = []string{"pc-ghost"}
+		nmd.PrecomputePostureValidation()
+
+		require.Contains(t, nmd.PostureValidation, "pc-ghost")
+		assert.Nil(t, nmd.PostureValidation["pc-ghost"])
+
+		c := compute(nmd, targetID)
+		assert.ElementsMatch(t, []string{targetID, "peer-src-pass", "peer-src-fail", "peer-other"}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("peers missing from the memo fall back to direct evaluation", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PostureValidation = map[string]map[string]bool{"pc-1": {"peer-src-pass": true}}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {"peer-src-fail": {}}}, c.PostureFailedPeers)
+	})
+}
+
+func TestNetworkMapData_GetPeerGroups(t *testing.T) {
+	target := newPeer(targetID, 1)
+	other := newPeer("peer-other", 2)
+	nmd := newNMD(target, other)
+	addGroup(nmd, "g-1", targetID, other.ID)
+	addGroup(nmd, "g-2", targetID)
+	addGroup(nmd, "g-3", other.ID)
+	nmd.Groups["g-nil"] = nil
+
+	assert.Equal(t, map[string]struct{}{"g-1": {}, "g-2": {}}, nmd.GetPeerGroups(targetID))
+	assert.Empty(t, nmd.GetPeerGroups("missing"))
+}
diff --git a/shared/management/networkmap/networkmapdata.go b/shared/management/networkmap/networkmapdata.go
new file mode 100644
index 000000000..e27605d64
--- /dev/null
+++ b/shared/management/networkmap/networkmapdata.go
@@ -0,0 +1,79 @@
+package networkmap
+
+import (
+	"sync"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// NetworkMapData is a dependency-light, slim twin of the server Account. It
+// carries only the state GetPeerNetworkMapComponents needs, expressed in the
+// fresh nmdata twin types. A builder converts an Account into a NetworkMapData
+// once per account; the per-peer components calculation then runs on this twin
+// with no reference back to the Account.
+type NetworkMapData struct { //nolint:revive // established name across the codebase
+	Peers            map[string]*nmdata.Peer
+	Groups           map[string]*nmdata.Group
+	Policies         []*nmdata.Policy
+	Routes           []*nmdata.Route
+	NameServerGroups []*nmdata.NameServerGroup
+	NetworkResources []*nmdata.NetworkResource
+
+	Network         *nmdata.Network
+	DNSSettings     *nmdata.DNSSettings
+	AccountSettings *nmdata.AccountSettingsInfo
+
+	PostureChecks map[string]*nmdata.PostureChecks
+
+	// PostureValidation holds the precomputed posture-check results, keyed by
+	// posture check ID then peer ID. Filled by PrecomputePostureValidation; a
+	// present but nil inner map marks a check ID that resolves to no posture
+	// check, which the calc treats as passing.
+	PostureValidation map[string]map[string]bool
+
+	AllowedUserIDs            map[string]struct{}
+	NetworkXIDToPublicID      map[string]string
+	PostureCheckXIDToPublicID map[string]string
+	ValidatedPeers            map[string]struct{}
+	ResourcePolicies          map[string][]*nmdata.Policy
+	Routers                   map[string]map[string]*nmdata.NetworkRouter
+	GroupIDToUserIDs          map[string][]string
+	DNSDomain                 string
+
+	// ProxyTargetedDomainResourceIDs is the account-level half of
+	// forcesRoutingPeerDNSResolution: domain network resources targeted by an
+	// enabled reverse-proxy service.
+	ProxyTargetedDomainResourceIDs map[string]struct{}
+
+	AppliedZoneCandidates    []AppliedZoneCandidate
+	PrivateServiceCandidates []PrivateServiceCandidate
+
+	// Services are the account's reverse-proxy services, persisted ones and
+	// the in-memory ones synthesised from agent-network state. They are the
+	// source of the proxy ACLs injectProxyPolicies synthesises, which no
+	// builder can load because they are never written to the database.
+	Services []*nmdata.Service
+
+	peerGroupsOnce sync.Once
+	peerGroupsIdx  map[string]map[string]struct{}
+
+	proxyPoliciesOnce sync.Once
+}
+
+// AppliedZoneCandidate is an account-level custom DNS zone reduced to the
+// per-peer decision the components calc still makes: include the zone only when
+// the peer belongs to one of its distribution groups. Record conversion is done
+// once at build time.
+type AppliedZoneCandidate struct {
+	DistributionGroups []string
+	Zone               nmdata.CustomZone
+}
+
+// PrivateServiceCandidate is a single private service's synthesized records,
+// carried per apex zone. The builder resolves proxy-cluster connectivity and
+// domain-suffix matching once; the calc merges the candidates whose AccessGroups
+// the peer belongs to, grouped by Zone.Domain.
+type PrivateServiceCandidate struct {
+	AccessGroups []string
+	Zone         nmdata.CustomZone
+}
diff --git a/shared/management/networkmap/nmdata/account_settings.go b/shared/management/networkmap/nmdata/account_settings.go
new file mode 100644
index 000000000..57e29e838
--- /dev/null
+++ b/shared/management/networkmap/nmdata/account_settings.go
@@ -0,0 +1,18 @@
+package nmdata
+
+import "time"
+
+// AccountSettingsInfo is the slim twin of types.AccountSettingsInfo.
+type AccountSettingsInfo struct {
+	PeerLoginExpirationEnabled      bool
+	PeerLoginExpiration             time.Duration
+	PeerInactivityExpirationEnabled bool
+	PeerInactivityExpiration        time.Duration
+	DNSDomain                       string
+	IPv6EnabledGroups               []string
+	RoutingPeerDNSResolutionEnabled bool
+	LazyConnectionEnabled           bool
+	AutoUpdateVersion               string
+	AutoUpdateAlways                bool
+	MetricsPushEnabled              bool
+}
diff --git a/shared/management/networkmap/nmdata/dns.go b/shared/management/networkmap/nmdata/dns.go
new file mode 100644
index 000000000..fe681af1a
--- /dev/null
+++ b/shared/management/networkmap/nmdata/dns.go
@@ -0,0 +1,18 @@
+package nmdata
+
+// SimpleRecord is the slim twin of dns.SimpleRecord.
+type SimpleRecord struct {
+	Name  string
+	Type  int
+	Class string
+	TTL   int
+	RData string
+}
+
+// CustomZone is the slim twin of dns.CustomZone.
+type CustomZone struct {
+	Domain               string
+	Records              []SimpleRecord
+	SearchDomainDisabled bool
+	NonAuthoritative     bool
+}
diff --git a/shared/management/networkmap/nmdata/dns_settings.go b/shared/management/networkmap/nmdata/dns_settings.go
new file mode 100644
index 000000000..69fd5f517
--- /dev/null
+++ b/shared/management/networkmap/nmdata/dns_settings.go
@@ -0,0 +1,6 @@
+package nmdata
+
+// DNSSettings is the slim twin of types.DNSSettings.
+type DNSSettings struct {
+	DisabledManagementGroups []string
+}
diff --git a/shared/management/networkmap/nmdata/group.go b/shared/management/networkmap/nmdata/group.go
new file mode 100644
index 000000000..1cd2cd15e
--- /dev/null
+++ b/shared/management/networkmap/nmdata/group.go
@@ -0,0 +1,30 @@
+package nmdata
+
+import "slices"
+
+// GroupAllName is the reserved name of the default group that contains every
+// peer in an account.
+const GroupAllName = "All"
+
+// Group is the slim twin of types.Group.
+type Group struct {
+	ID        string
+	Name      string
+	PublicID  string
+	Peers     []string
+	Resources []Resource
+}
+
+func (g *Group) IsGroupAll() bool {
+	return g.Name == GroupAllName
+}
+
+func (g *Group) Copy() *Group {
+	return &Group{
+		ID:        g.ID,
+		Name:      g.Name,
+		PublicID:  g.PublicID,
+		Peers:     slices.Clone(g.Peers),
+		Resources: slices.Clone(g.Resources),
+	}
+}
diff --git a/shared/management/networkmap/nmdata/group_test.go b/shared/management/networkmap/nmdata/group_test.go
new file mode 100644
index 000000000..20aaa240f
--- /dev/null
+++ b/shared/management/networkmap/nmdata/group_test.go
@@ -0,0 +1,84 @@
+package nmdata
+
+import (
+	"reflect"
+	"testing"
+)
+
+// TestGroupCopy_AllFieldsCopied fills every Group field with a unique non-zero
+// value derived from its field path, so a field added to Group but forgotten
+// in Copy fails here by name without the test needing an update. The unique
+// per-path values also catch fields swapped inside Copy.
+func TestGroupCopy_AllFieldsCopied(t *testing.T) {
+	src := &Group{}
+	seed := 0
+	fillValue(t, reflect.ValueOf(src).Elem(), "Group", &seed)
+
+	copied := src.Copy()
+
+	srcV := reflect.ValueOf(src).Elem()
+	copiedV := reflect.ValueOf(copied).Elem()
+	for i := 0; i < srcV.NumField(); i++ {
+		name := srcV.Type().Field(i).Name
+		if !reflect.DeepEqual(srcV.Field(i).Interface(), copiedV.Field(i).Interface()) {
+			t.Errorf("field %s not copied: src=%#v copy=%#v",
+				name, srcV.Field(i).Interface(), copiedV.Field(i).Interface())
+		}
+	}
+
+	for i := 0; i < srcV.NumField(); i++ {
+		f := srcV.Field(i)
+		if f.Kind() != reflect.Slice || f.Len() == 0 {
+			continue
+		}
+		name := srcV.Type().Field(i).Name
+		fillValue(t, f.Index(0), name+"-mutated", &seed)
+		if reflect.DeepEqual(f.Interface(), copiedV.Field(i).Interface()) {
+			t.Errorf("field %s shares memory with the copy", name)
+		}
+	}
+}
+
+// fillValue sets v to a deterministic non-zero value derived from its field
+// path. Kinds it does not handle fail the test loudly, so the filler is
+// extended together with the struct instead of silently under-testing new
+// fields.
+func fillValue(t *testing.T, v reflect.Value, path string, seed *int) {
+	t.Helper()
+
+	switch v.Kind() {
+	case reflect.String:
+		v.SetString(path)
+	case reflect.Bool:
+		v.SetBool(true)
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		*seed++
+		v.SetInt(int64(*seed))
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		*seed++
+		v.SetUint(uint64(*seed))
+	case reflect.Float32, reflect.Float64:
+		*seed++
+		v.SetFloat(float64(*seed))
+	case reflect.Slice:
+		s := reflect.MakeSlice(v.Type(), 2, 2)
+		fillValue(t, s.Index(0), path+"[0]", seed)
+		fillValue(t, s.Index(1), path+"[1]", seed)
+		v.Set(s)
+	case reflect.Struct:
+		settable := 0
+		for i := 0; i < v.NumField(); i++ {
+			f := v.Field(i)
+			if !f.CanSet() {
+				continue
+			}
+			settable++
+			fillValue(t, f, path+"."+v.Type().Field(i).Name, seed)
+		}
+		if settable == 0 {
+			t.Fatalf("struct %s at %s has no settable fields — extend fillValue to construct it", v.Type(), path)
+		}
+	default:
+		t.Fatalf("unsupported kind %s at %s — extend fillValue", v.Kind(), path)
+	}
+}
diff --git a/shared/management/networkmap/nmdata/nameserver.go b/shared/management/networkmap/nmdata/nameserver.go
new file mode 100644
index 000000000..2698dd8d0
--- /dev/null
+++ b/shared/management/networkmap/nmdata/nameserver.go
@@ -0,0 +1,24 @@
+package nmdata
+
+import "net/netip"
+
+// NameServerGroup is the slim twin of dns.NameServerGroup.
+type NameServerGroup struct {
+	ID                   string
+	PublicID             string
+	Name                 string
+	Description          string
+	NameServers          []NameServer
+	Groups               []string
+	Primary              bool
+	Domains              []string
+	Enabled              bool
+	SearchDomainsEnabled bool
+}
+
+// NameServer is the slim twin of dns.NameServer.
+type NameServer struct {
+	IP     netip.Addr
+	NSType int
+	Port   int
+}
diff --git a/shared/management/networkmap/nmdata/network.go b/shared/management/networkmap/nmdata/network.go
new file mode 100644
index 000000000..72b6502ef
--- /dev/null
+++ b/shared/management/networkmap/nmdata/network.go
@@ -0,0 +1,16 @@
+package nmdata
+
+import "net"
+
+// Network is the slim twin of types.Network.
+type Network struct {
+	Identifier string
+	Net        net.IPNet
+	NetV6      net.IPNet
+	Dns        string
+	Serial     int64
+}
+
+func (n *Network) CurrentSerial() uint64 {
+	return uint64(n.Serial)
+}
diff --git a/shared/management/networkmap/nmdata/network_resource.go b/shared/management/networkmap/nmdata/network_resource.go
new file mode 100644
index 000000000..44f3c477b
--- /dev/null
+++ b/shared/management/networkmap/nmdata/network_resource.go
@@ -0,0 +1,18 @@
+package nmdata
+
+import "net/netip"
+
+// NetworkResource is the slim twin of resources/types.NetworkResource.
+type NetworkResource struct {
+	ID          string
+	NetworkID   string
+	AccountID   string
+	PublicID    string
+	Name        string
+	Description string
+	Type        string
+	Address     string // TODO: isn't persisted in the DB
+	Domain      string
+	Prefix      netip.Prefix
+	Enabled     bool
+}
diff --git a/shared/management/networkmap/nmdata/network_router.go b/shared/management/networkmap/nmdata/network_router.go
new file mode 100644
index 000000000..fd5df37c4
--- /dev/null
+++ b/shared/management/networkmap/nmdata/network_router.go
@@ -0,0 +1,10 @@
+package nmdata
+
+// NetworkRouter is the slim twin of routers/types.NetworkRouter.
+type NetworkRouter struct {
+	PublicID   string
+	PeerGroups []string
+	Masquerade bool
+	Metric     int
+	Enabled    bool
+}
diff --git a/shared/management/networkmap/nmdata/peer.go b/shared/management/networkmap/nmdata/peer.go
new file mode 100644
index 000000000..3ceb1dbc1
--- /dev/null
+++ b/shared/management/networkmap/nmdata/peer.go
@@ -0,0 +1,129 @@
+package nmdata
+
+import (
+	"net"
+	"net/netip"
+	"slices"
+	"time"
+)
+
+// Peer capability constants mirror the proto enum values.
+const (
+	PeerCapabilitySourcePrefixes      int32 = 1
+	PeerCapabilityIPv6Overlay         int32 = 2
+	PeerCapabilityComponentNetworkMap int32 = 3
+)
+
+// Peer is the slim twin of peer.Peer.
+type Peer struct {
+	ID                     string
+	Key                    string
+	SSHKey                 string
+	DNSLabel               string
+	UserID                 string
+	SSHEnabled             bool
+	LoginExpirationEnabled bool
+	LastLogin              *time.Time
+	IP                     netip.Addr
+	IPv6                   netip.Addr
+	RequiresApproval       bool
+	ExtraDNSLabels         []string
+	Meta                   PeerSystemMeta
+	ProxyMeta              ProxyMeta
+	Location               PeerLocation
+}
+
+// ProxyMeta is the slim twin of peer.ProxyMeta.
+type ProxyMeta struct {
+	Embedded bool
+	Cluster  string
+}
+
+// PeerSystemMeta is the slim twin of peer.PeerSystemMeta.
+type PeerSystemMeta struct {
+	WtVersion          string
+	GoOS               string
+	OSVersion          string
+	KernelVersion      string
+	NetworkAddresses   []NetworkAddress
+	Files              []File
+	Capabilities       []int32
+	Flags              Flags
+	SyncMessageVersion int
+}
+
+// Flags is the slim twin of peer.Flags.
+type Flags struct {
+	ServerSSHAllowed bool
+	DisableIPv6      bool
+}
+
+// NetworkAddress is the slim twin of peer.NetworkAddress.
+type NetworkAddress struct {
+	NetIP netip.Prefix
+}
+
+// File is the slim twin of peer.File.
+type File struct {
+	Path             string
+	ProcessIsRunning bool
+}
+
+// PeerLocation is the slim twin of peer.Location.
+type PeerLocation struct {
+	CountryCode  string
+	CityName     string
+	ConnectionIP net.IP
+}
+
+func (p *Peer) HasCapability(capability int32) bool {
+	return slices.Contains(p.Meta.Capabilities, capability)
+}
+
+func (p *Peer) SupportsIPv6() bool {
+	return !p.Meta.Flags.DisableIPv6 && p.HasCapability(PeerCapabilityIPv6Overlay)
+}
+
+func (p *Peer) SupportsSourcePrefixes() bool {
+	return p.HasCapability(PeerCapabilitySourcePrefixes)
+}
+
+func (p *Peer) AddedWithSSOLogin() bool {
+	return p.UserID != ""
+}
+
+func (p *Peer) FQDN(dnsDomain string) string {
+	if dnsDomain == "" {
+		return ""
+	}
+	return p.DNSLabel + "." + dnsDomain
+}
+
+func (p *Peer) GetLastLogin() time.Time {
+	if p.LastLogin != nil {
+		return *p.LastLogin
+	}
+	return time.Time{}
+}
+
+// SessionExpiresAt mirrors peer.Peer.SessionExpiresAt.
+func (p *Peer) SessionExpiresAt(accountExpirationEnabled bool, expiresIn time.Duration) time.Time {
+	if !accountExpirationEnabled || !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled {
+		return time.Time{}
+	}
+	last := p.GetLastLogin()
+	if last.IsZero() {
+		return time.Time{}
+	}
+	return last.Add(expiresIn).UTC()
+}
+
+func (p *Peer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) {
+	if !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled {
+		return false, 0
+	}
+	expiresAt := p.GetLastLogin().Add(expiresIn)
+	now := time.Now()
+	timeLeft := expiresAt.Sub(now)
+	return timeLeft <= 0, timeLeft
+}
diff --git a/shared/management/networkmap/nmdata/policy.go b/shared/management/networkmap/nmdata/policy.go
new file mode 100644
index 000000000..df0c77518
--- /dev/null
+++ b/shared/management/networkmap/nmdata/policy.go
@@ -0,0 +1,96 @@
+package nmdata
+
+const (
+	policyRuleProtocolALL = "all"
+	policyRuleProtocolTCP = "tcp"
+
+	defaultSSHPortString        = "22"
+	nativeSSHPortString         = "22022"
+	defaultSSHPortNumber uint16 = 22
+	nativeSSHPortNumber  uint16 = 22022
+)
+
+// Policy is the slim twin of types.Policy.
+type Policy struct {
+	ID                  string
+	PublicID            string
+	Enabled             bool
+	SourcePostureChecks []string
+	Rules               []*PolicyRule
+}
+
+// PolicyRule is the slim twin of types.PolicyRule.
+type PolicyRule struct {
+	ID                  string
+	PolicyID            string
+	Enabled             bool
+	Action              string
+	Protocol            string
+	Bidirectional       bool
+	Sources             []string
+	Destinations        []string
+	SourceResource      Resource
+	DestinationResource Resource
+	Ports               []string
+	PortRanges          []RulePortRange
+	AuthorizedGroups    map[string][]string
+	AuthorizedUser      string
+}
+
+// RulePortRange is the slim twin of types.RulePortRange.
+type RulePortRange struct {
+	Start uint16
+	End   uint16
+}
+
+// Resource is the slim twin of types.Resource.
+type Resource struct {
+	ID   string
+	Type string
+}
+
+func (p *Policy) SourceGroups() []string {
+	if len(p.Rules) == 1 && p.Rules[0] != nil {
+		return p.Rules[0].Sources
+	}
+	groups := make(map[string]struct{}, len(p.Rules))
+	for _, rule := range p.Rules {
+		if rule == nil {
+			continue
+		}
+		for _, source := range rule.Sources {
+			groups[source] = struct{}{}
+		}
+	}
+
+	groupIDs := make([]string, 0, len(groups))
+	for groupID := range groups {
+		groupIDs = append(groupIDs, groupID)
+	}
+
+	return groupIDs
+}
+
+// PolicyRuleImpliesLegacySSH is the twin-typed sibling of types.PolicyRuleImpliesLegacySSH.
+func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
+	return rule.Protocol == policyRuleProtocolALL ||
+		(rule.Protocol == policyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
+}
+
+func portRangeIncludesSSH(portRanges []RulePortRange) bool {
+	for _, pr := range portRanges {
+		if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
+			return true
+		}
+	}
+	return false
+}
+
+func portsIncludesSSH(ports []string) bool {
+	for _, port := range ports {
+		if port == defaultSSHPortString || port == nativeSSHPortString {
+			return true
+		}
+	}
+	return false
+}
diff --git a/shared/management/networkmap/nmdata/posture.go b/shared/management/networkmap/nmdata/posture.go
new file mode 100644
index 000000000..dc1753791
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture.go
@@ -0,0 +1,67 @@
+package nmdata
+
+const (
+	checkActionAllow = "allow"
+	checkActionDeny  = "deny"
+)
+
+// PostureChecks is the slim twin of posture.Checks.
+type PostureChecks struct {
+	ID     string
+	Checks ChecksDefinition
+}
+
+// ChecksDefinition is the slim twin of posture.ChecksDefinition.
+type ChecksDefinition struct {
+	NBVersionCheck        *NBVersionCheck
+	OSVersionCheck        *OSVersionCheck
+	GeoLocationCheck      *GeoLocationCheck
+	PeerNetworkRangeCheck *PeerNetworkRangeCheck
+	ProcessCheck          *ProcessCheck
+}
+
+// Check is the slim twin of posture.Check. It is sealed: only the check types
+// in this package implement it.
+type Check interface {
+	check(peer *Peer) (bool, error)
+}
+
+// Passes reports whether the peer satisfies every check in this bundle. It
+// mirrors the server posture path: a check returning (false, _) — including on
+// an evaluation error — fails the bundle.
+func (pc *PostureChecks) Passes(peer *Peer) bool {
+	return PassesChecks(pc.GetChecks(), peer)
+}
+
+// PassesChecks is Passes over an already built check set, for callers that
+// evaluate many peers against the same bundle.
+func PassesChecks(checks []Check, peer *Peer) bool {
+	for _, c := range checks {
+		valid, _ := c.check(peer)
+		if !valid {
+			return false
+		}
+	}
+	return true
+}
+
+// GetChecks returns the initialized checks in the same order as posture.Checks.GetChecks.
+func (pc *PostureChecks) GetChecks() []Check {
+	var checks []Check
+	if pc.Checks.NBVersionCheck != nil {
+		checks = append(checks, pc.Checks.NBVersionCheck)
+	}
+	if pc.Checks.OSVersionCheck != nil {
+		checks = append(checks, pc.Checks.OSVersionCheck)
+	}
+	if pc.Checks.GeoLocationCheck != nil {
+		checks = append(checks, pc.Checks.GeoLocationCheck)
+	}
+	if pc.Checks.PeerNetworkRangeCheck != nil {
+		checks = append(checks, pc.Checks.PeerNetworkRangeCheck)
+	}
+	if pc.Checks.ProcessCheck != nil {
+		checks = append(checks, pc.Checks.ProcessCheck)
+	}
+	return checks
+}
diff --git a/shared/management/networkmap/nmdata/posture_geo_location.go b/shared/management/networkmap/nmdata/posture_geo_location.go
new file mode 100644
index 000000000..18b0919b2
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_geo_location.go
@@ -0,0 +1,45 @@
+package nmdata
+
+import "fmt"
+
+// GeoLocation is the slim twin of posture.Location.
+type GeoLocation struct {
+	CountryCode string
+	CityName    string
+}
+
+// GeoLocationCheck is the slim twin of posture.GeoLocationCheck.
+type GeoLocationCheck struct {
+	Locations []GeoLocation
+	Action    string
+}
+
+func (g *GeoLocationCheck) check(peer *Peer) (bool, error) {
+	if peer.Location.CountryCode == "" && peer.Location.CityName == "" {
+		return false, fmt.Errorf("peer's location is not set")
+	}
+
+	for _, loc := range g.Locations {
+		if loc.CountryCode == peer.Location.CountryCode {
+			if loc.CityName == "" || loc.CityName == peer.Location.CityName {
+				switch g.Action {
+				case checkActionDeny:
+					return false, nil
+				case checkActionAllow:
+					return true, nil
+				default:
+					return false, fmt.Errorf("invalid geo location action: %s", g.Action)
+				}
+			}
+		}
+	}
+
+	if g.Action == checkActionDeny {
+		return true, nil
+	}
+	if g.Action == checkActionAllow {
+		return false, nil
+	}
+
+	return false, fmt.Errorf("invalid geo location action: %s", g.Action)
+}
diff --git a/shared/management/networkmap/nmdata/posture_nb_version.go b/shared/management/networkmap/nmdata/posture_nb_version.go
new file mode 100644
index 000000000..3d82a4c80
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_nb_version.go
@@ -0,0 +1,38 @@
+package nmdata
+
+import (
+	"strings"
+
+	"github.com/hashicorp/go-version"
+)
+
+// NBVersionCheck is the slim twin of posture.NBVersionCheck.
+type NBVersionCheck struct {
+	MinVersion string
+}
+
+func (n *NBVersionCheck) check(peer *Peer) (bool, error) {
+	return meetsMinVersion(n.MinVersion, peer.Meta.WtVersion)
+}
+
+func meetsMinVersion(minVer, peerVer string) (bool, error) {
+	peerVer = sanitizeVersion(peerVer)
+	minVer = sanitizeVersion(minVer)
+
+	peerNBVer, err := version.NewVersion(peerVer)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := version.NewConstraint(">= " + minVer)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVer), nil
+}
+
+func sanitizeVersion(v string) string {
+	parts := strings.Split(v, "-")
+	return parts[0]
+}
diff --git a/shared/management/networkmap/nmdata/posture_network.go b/shared/management/networkmap/nmdata/posture_network.go
new file mode 100644
index 000000000..d8dd2cf00
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_network.go
@@ -0,0 +1,62 @@
+package nmdata
+
+import (
+	"fmt"
+	"net/netip"
+)
+
+// PeerNetworkRangeCheck is the slim twin of posture.PeerNetworkRangeCheck.
+type PeerNetworkRangeCheck struct {
+	Action string
+	Ranges []netip.Prefix
+}
+
+func (p *PeerNetworkRangeCheck) check(peer *Peer) (bool, error) {
+	peerPrefixes := make([]netip.Prefix, 0, len(peer.Meta.NetworkAddresses)+1)
+	for _, peerNetAddr := range peer.Meta.NetworkAddresses {
+		peerPrefixes = append(peerPrefixes, peerNetAddr.NetIP)
+	}
+	if connIP := peer.Location.ConnectionIP; len(connIP) > 0 {
+		if addr, ok := netip.AddrFromSlice(connIP); ok {
+			addr = addr.Unmap()
+			peerPrefixes = append(peerPrefixes, netip.PrefixFrom(addr, addr.BitLen()))
+		}
+	}
+
+	if len(peerPrefixes) == 0 {
+		return false, fmt.Errorf("peer's does not contain peer network range addresses")
+	}
+
+	for _, peerPrefix := range peerPrefixes {
+		for _, rangePrefix := range p.Ranges {
+			if !prefixContains(rangePrefix, peerPrefix) {
+				continue
+			}
+			switch p.Action {
+			case checkActionDeny:
+				return false, nil
+			case checkActionAllow:
+				return true, nil
+			default:
+				return false, fmt.Errorf("invalid peer network range check action: %s", p.Action)
+			}
+		}
+	}
+
+	if p.Action == checkActionDeny {
+		return true, nil
+	}
+	if p.Action == checkActionAllow {
+		return false, nil
+	}
+
+	return false, fmt.Errorf("invalid peer network range check action: %s", p.Action)
+}
+
+func prefixContains(outer, inner netip.Prefix) bool {
+	outer = outer.Masked()
+	inner = inner.Masked()
+	return outer.Bits() <= inner.Bits() &&
+		outer.Addr().BitLen() == inner.Addr().BitLen() &&
+		outer.Contains(inner.Addr())
+}
diff --git a/shared/management/networkmap/nmdata/posture_os_version.go b/shared/management/networkmap/nmdata/posture_os_version.go
new file mode 100644
index 000000000..779bd2ac3
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_os_version.go
@@ -0,0 +1,79 @@
+package nmdata
+
+import (
+	"strings"
+
+	"github.com/hashicorp/go-version"
+)
+
+// MinVersionCheck is the slim twin of posture.MinVersionCheck.
+type MinVersionCheck struct {
+	MinVersion string
+}
+
+// MinKernelVersionCheck is the slim twin of posture.MinKernelVersionCheck.
+type MinKernelVersionCheck struct {
+	MinKernelVersion string
+}
+
+// OSVersionCheck is the slim twin of posture.OSVersionCheck.
+type OSVersionCheck struct {
+	Android *MinVersionCheck
+	Darwin  *MinVersionCheck
+	Ios     *MinVersionCheck
+	Linux   *MinKernelVersionCheck
+	Windows *MinKernelVersionCheck
+}
+
+func (c *OSVersionCheck) check(peer *Peer) (bool, error) {
+	switch peer.Meta.GoOS {
+	case "android":
+		return checkMinVersion(peer.Meta.OSVersion, c.Android)
+	case "darwin":
+		return checkMinVersion(peer.Meta.OSVersion, c.Darwin)
+	case "ios":
+		return checkMinVersion(peer.Meta.OSVersion, c.Ios)
+	case "linux":
+		kernelVersion := strings.Split(peer.Meta.KernelVersion, "-")[0]
+		return checkMinKernelVersion(kernelVersion, c.Linux)
+	case "windows":
+		return checkMinKernelVersion(peer.Meta.KernelVersion, c.Windows)
+	}
+	return true, nil
+}
+
+func checkMinVersion(peerVersion string, check *MinVersionCheck) (bool, error) {
+	if check == nil {
+		return false, nil
+	}
+
+	peerNBVersion, err := version.NewVersion(peerVersion)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := version.NewConstraint(">= " + check.MinVersion)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVersion), nil
+}
+
+func checkMinKernelVersion(peerVersion string, check *MinKernelVersionCheck) (bool, error) {
+	if check == nil {
+		return false, nil
+	}
+
+	peerNBVersion, err := version.NewVersion(peerVersion)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := version.NewConstraint(">= " + check.MinKernelVersion)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVersion), nil
+}
diff --git a/shared/management/networkmap/nmdata/posture_process.go b/shared/management/networkmap/nmdata/posture_process.go
new file mode 100644
index 000000000..3d35613b5
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_process.go
@@ -0,0 +1,56 @@
+package nmdata
+
+import (
+	"fmt"
+	"slices"
+)
+
+// Process is the slim twin of posture.Process.
+type Process struct {
+	LinuxPath   string
+	MacPath     string
+	WindowsPath string
+}
+
+// ProcessCheck is the slim twin of posture.ProcessCheck.
+type ProcessCheck struct {
+	Processes []Process
+}
+
+func (p *ProcessCheck) check(peer *Peer) (bool, error) {
+	peerActiveProcesses := extractPeerActiveProcesses(peer.Meta.Files)
+
+	var pathSelector func(Process) string
+	switch peer.Meta.GoOS {
+	case "linux":
+		pathSelector = func(process Process) string { return process.LinuxPath }
+	case "darwin":
+		pathSelector = func(process Process) string { return process.MacPath }
+	case "windows":
+		pathSelector = func(process Process) string { return process.WindowsPath }
+	default:
+		return false, fmt.Errorf("unsupported peer's operating system: %s", peer.Meta.GoOS)
+	}
+
+	return p.areAllProcessesRunning(peerActiveProcesses, pathSelector), nil
+}
+
+func (p *ProcessCheck) areAllProcessesRunning(activeProcesses []string, pathSelector func(Process) string) bool {
+	for _, process := range p.Processes {
+		path := pathSelector(process)
+		if path == "" || !slices.Contains(activeProcesses, path) {
+			return false
+		}
+	}
+	return true
+}
+
+func extractPeerActiveProcesses(files []File) []string {
+	activeProcesses := make([]string, 0, len(files))
+	for _, file := range files {
+		if file.ProcessIsRunning {
+			activeProcesses = append(activeProcesses, file.Path)
+		}
+	}
+	return activeProcesses
+}
diff --git a/shared/management/networkmap/nmdata/route.go b/shared/management/networkmap/nmdata/route.go
new file mode 100644
index 000000000..e2301f094
--- /dev/null
+++ b/shared/management/networkmap/nmdata/route.go
@@ -0,0 +1,108 @@
+package nmdata
+
+import (
+	"net/netip"
+	"slices"
+	"strings"
+
+	"github.com/netbirdio/netbird/shared/management/domain"
+)
+
+// NetworkType mirrors route.NetworkType iota values.
+const (
+	NetworkTypeInvalid = 0
+	NetworkTypeIPv4    = 1
+	NetworkTypeIPv6    = 2
+	NetworkTypeDomain  = 3
+
+	haSeparator = "|"
+)
+
+// Route is the slim twin of route.Route.
+type Route struct {
+	ID                  string
+	AccountID           string
+	PublicID            string
+	Network             netip.Prefix
+	Domains             domain.List
+	KeepRoute           bool
+	NetID               string
+	Description         string
+	Peer                string
+	PeerID              string
+	PeerGroups          []string
+	NetworkType         int
+	Masquerade          bool
+	Metric              int
+	Enabled             bool
+	Groups              []string
+	AccessControlGroups []string
+	SkipAutoApply       bool
+}
+
+func (r *Route) Equal(other *Route) bool {
+	if r == nil && other == nil {
+		return true
+	} else if r == nil || other == nil {
+		return false
+	}
+
+	return other.ID == r.ID &&
+		other.Description == r.Description &&
+		other.NetID == r.NetID &&
+		other.Network == r.Network &&
+		slices.Equal(r.Domains, other.Domains) &&
+		other.KeepRoute == r.KeepRoute &&
+		other.NetworkType == r.NetworkType &&
+		other.Peer == r.Peer &&
+		other.PeerID == r.PeerID &&
+		other.Metric == r.Metric &&
+		other.Masquerade == r.Masquerade &&
+		other.Enabled == r.Enabled &&
+		slices.Equal(r.Groups, other.Groups) &&
+		slices.Equal(r.PeerGroups, other.PeerGroups) &&
+		slices.Equal(r.AccessControlGroups, other.AccessControlGroups) &&
+		other.SkipAutoApply == r.SkipAutoApply
+}
+
+func (r *Route) IsDynamic() bool {
+	return r.NetworkType == NetworkTypeDomain
+}
+
+func (r *Route) NetString() string {
+	if r.IsDynamic() && r.Domains != nil {
+		return r.Domains.SafeString()
+	}
+	return r.Network.String()
+}
+
+func (r *Route) GetHAUniqueID() string {
+	return r.NetID + haSeparator + r.NetString()
+}
+
+func (r *Route) GetResourceID() string {
+	return strings.Split(r.ID, ":")[0]
+}
+
+func (r *Route) Copy() *Route {
+	return &Route{
+		ID:                  r.ID,
+		AccountID:           r.AccountID,
+		PublicID:            r.PublicID,
+		Network:             r.Network,
+		Domains:             slices.Clone(r.Domains),
+		KeepRoute:           r.KeepRoute,
+		NetID:               r.NetID,
+		Description:         r.Description,
+		Peer:                r.Peer,
+		PeerID:              r.PeerID,
+		PeerGroups:          slices.Clone(r.PeerGroups),
+		NetworkType:         r.NetworkType,
+		Masquerade:          r.Masquerade,
+		Metric:              r.Metric,
+		Enabled:             r.Enabled,
+		Groups:              slices.Clone(r.Groups),
+		AccessControlGroups: slices.Clone(r.AccessControlGroups),
+		SkipAutoApply:       r.SkipAutoApply,
+	}
+}
diff --git a/shared/management/networkmap/nmdata/service.go b/shared/management/networkmap/nmdata/service.go
new file mode 100644
index 000000000..63557c51e
--- /dev/null
+++ b/shared/management/networkmap/nmdata/service.go
@@ -0,0 +1,25 @@
+package nmdata
+
+// Service is the slim twin of the reverse-proxy service.Service. It carries
+// only the state proxy-policy injection reads: the persisted reverse-proxy
+// services and the in-memory ones synthesised from agent-network state, which
+// are never written to the database.
+type Service struct {
+	ID           string
+	Enabled      bool
+	Private      bool
+	Mode         string
+	ProxyCluster string
+	AccessGroups []string
+	Targets      []*ServiceTarget
+}
+
+// ServiceTarget is the slim twin of service.Target.
+type ServiceTarget struct {
+	Enabled    bool
+	Path       string
+	Port       uint16
+	Protocol   string
+	TargetID   string
+	TargetType string
+}
diff --git a/shared/management/networkmap/peers_custom_zone.go b/shared/management/networkmap/peers_custom_zone.go
new file mode 100644
index 000000000..063844358
--- /dev/null
+++ b/shared/management/networkmap/peers_custom_zone.go
@@ -0,0 +1,111 @@
+package networkmap
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/hashicorp/go-multierror"
+	"github.com/miekg/dns"
+	log "github.com/sirupsen/logrus"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const peersZoneRecordTTL = 300
+
+// PeersCustomZone builds the peers DNS zone from twin peer rows. It is the
+// single source of the zone-record logic; Account.GetPeersCustomZone delegates
+// here via twins.
+func PeersCustomZone(ctx context.Context, accountID string, dnsDomain string, peers map[string]*nmdata.Peer, ipv6AllowedPeers map[string]struct{}) nmdata.CustomZone {
+	var merr *multierror.Error
+
+	if dnsDomain == "" {
+		log.WithContext(ctx).Error("no dns domain is set, returning empty zone")
+		return nmdata.CustomZone{}
+	}
+
+	customZone := nmdata.CustomZone{
+		Domain:  dns.Fqdn(dnsDomain),
+		Records: make([]nmdata.SimpleRecord, 0, len(peers)),
+	}
+
+	domainSuffix := "." + dnsDomain
+
+	var sb strings.Builder
+	for _, peer := range peers {
+		if peer == nil {
+			continue
+		}
+		if peer.DNSLabel == "" {
+			merr = multierror.Append(merr, fmt.Errorf("peer %s has an empty DNS label", peer.ID))
+			continue
+		}
+
+		sb.Grow(len(peer.DNSLabel) + len(domainSuffix))
+		sb.WriteString(peer.DNSLabel)
+		sb.WriteString(domainSuffix)
+
+		fqdn := sb.String()
+		customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+			Name:  fqdn,
+			Type:  int(dns.TypeA),
+			Class: nbdns.DefaultClass,
+			TTL:   peersZoneRecordTTL,
+			RData: peer.IP.String(),
+		})
+		// Only advertise AAAA for peers that have a valid IPv6, whose client supports it,
+		// and that belong to an IPv6-enabled group. Old clients don't configure v6 on their
+		// WireGuard interface, so resolving their AAAA causes connections to hang.
+		// Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate
+		// to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA
+		// records refresh when a peer first reports the IPv6 overlay capability.
+		_, peerAllowed := ipv6AllowedPeers[peer.ID]
+		hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed
+		if hasIPv6 {
+			customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+				Name:  fqdn,
+				Type:  int(dns.TypeAAAA),
+				Class: nbdns.DefaultClass,
+				TTL:   peersZoneRecordTTL,
+				RData: peer.IPv6.String(),
+			})
+		}
+		sb.Reset()
+
+		for _, extraLabel := range peer.ExtraDNSLabels {
+			sb.Grow(len(extraLabel) + len(domainSuffix))
+			sb.WriteString(extraLabel)
+			sb.WriteString(domainSuffix)
+
+			extraFqdn := sb.String()
+			customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+				Name:  extraFqdn,
+				Type:  int(dns.TypeA),
+				Class: nbdns.DefaultClass,
+				TTL:   peersZoneRecordTTL,
+				RData: peer.IP.String(),
+			})
+			if hasIPv6 {
+				customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+					Name:  extraFqdn,
+					Type:  int(dns.TypeAAAA),
+					Class: nbdns.DefaultClass,
+					TTL:   peersZoneRecordTTL,
+					RData: peer.IPv6.String(),
+				})
+			}
+			sb.Reset()
+		}
+
+	}
+
+	go func() {
+		if merr != nil {
+			log.WithContext(ctx).Errorf("error generating custom zone for account %s: %v", accountID, merr)
+		}
+	}()
+
+	return customZone
+}
diff --git a/shared/management/networkmap/proxypolicies.go b/shared/management/networkmap/proxypolicies.go
new file mode 100644
index 000000000..7a7c805a6
--- /dev/null
+++ b/shared/management/networkmap/proxypolicies.go
@@ -0,0 +1,209 @@
+package networkmap
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/types"
+)
+
+const (
+	serviceModeUDP = "udp"
+
+	privateServicePortHTTP  = 80
+	privateServicePortHTTPS = 443
+)
+
+// InjectProxyPolicies synthesises the in-memory ACLs that carry reverse-proxy
+// traffic and appends them to the twin's policies. They are never persisted,
+// so no builder can load them: a proxy-access policy lets a cluster's proxy
+// peers reach each enabled target of a service, and a private-access policy
+// lets a private service's AccessGroups reach those proxy peers on HTTP(S).
+//
+// GetPeerNetworkMapComponents calls it, so every caller of the twin gets the
+// same policy set no matter which builder produced it. It runs at most once
+// per twin, and is safe to call again to force the synthesis early.
+func (nmd *NetworkMapData) InjectProxyPolicies() {
+	nmd.proxyPoliciesOnce.Do(nmd.injectProxyPolicies)
+}
+
+func (nmd *NetworkMapData) injectProxyPolicies() {
+	if len(nmd.Services) == 0 {
+		return
+	}
+
+	proxyPeersByCluster := nmd.proxyPeersByCluster()
+	if len(proxyPeersByCluster) == 0 {
+		return
+	}
+
+	for _, svc := range nmd.Services {
+		if svc == nil || !svc.Enabled {
+			continue
+		}
+
+		proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+		for _, target := range svc.Targets {
+			if target == nil || !target.Enabled {
+				continue
+			}
+			port, ok := resolveTargetPort(target)
+			if !ok {
+				continue
+			}
+			for _, proxyPeer := range proxyPeers {
+				nmd.addInjectedPolicy(proxyAccessPolicy(svc, target, proxyPeer, port))
+			}
+		}
+
+		nmd.injectPrivateServicePolicies(svc, proxyPeers)
+	}
+}
+
+// injectPrivateServicePolicies synthesises AccessGroups → cluster proxy peers on TCP 80/443.
+func (nmd *NetworkMapData) injectPrivateServicePolicies(svc *nmdata.Service, proxyPeers []*nmdata.Peer) {
+	if !svc.Private || len(svc.AccessGroups) == 0 || len(proxyPeers) == 0 {
+		return
+	}
+
+	// A service's AccessGroups can name groups that no longer exist — persisted
+	// services and the agent-network synthesiser both carry the ids verbatim from
+	// their own state. An unresolvable source authorises nothing, so drop it here
+	// rather than let the network-map assembly resolve it to a nil group.
+	sources := nmd.existingGroupIDs(svc.AccessGroups)
+	if len(sources) == 0 {
+		return
+	}
+
+	for _, proxyPeer := range proxyPeers {
+		nmd.addInjectedPolicy(privateAccessPolicy(svc, proxyPeer, sources))
+	}
+}
+
+// addInjectedPolicy appends the policy to the twin's policy set, and to the
+// policies of the network resource it targets — mirroring the account path,
+// where the resource-policy map was built after injection.
+func (nmd *NetworkMapData) addInjectedPolicy(policy *nmdata.Policy) {
+	nmd.Policies = append(nmd.Policies, policy)
+
+	resourceID := policy.Rules[0].DestinationResource.ID
+	if resourceID == "" {
+		return
+	}
+	for _, resource := range nmd.NetworkResources {
+		if resource == nil || !resource.Enabled || resource.ID != resourceID {
+			continue
+		}
+		if nmd.ResourcePolicies == nil {
+			nmd.ResourcePolicies = make(map[string][]*nmdata.Policy)
+		}
+		nmd.ResourcePolicies[resourceID] = append(nmd.ResourcePolicies[resourceID], policy)
+		return
+	}
+}
+
+func proxyAccessPolicy(svc *nmdata.Service, target *nmdata.ServiceTarget, proxyPeer *nmdata.Peer, port uint16) *nmdata.Policy {
+	policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, target.Path)
+
+	protocol := types.PolicyRuleProtocolTCP
+	if svc.Mode == serviceModeUDP {
+		protocol = types.PolicyRuleProtocolUDP
+	}
+
+	return &nmdata.Policy{
+		ID: policyID,
+		// The envelope encoder puts public ids on the wire and degrades to an
+		// empty one when a policy has none. A synthesised policy has no
+		// persisted row to take a public id from, and its own id is already
+		// stable and unique, so it serves as both.
+		PublicID: policyID,
+		Enabled:  true,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  policyID,
+				PolicyID:            policyID,
+				Enabled:             true,
+				SourceResource:      nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)},
+				DestinationResource: nmdata.Resource{ID: target.TargetID, Type: target.TargetType},
+				Bidirectional:       false,
+				Protocol:            string(protocol),
+				Action:              string(types.PolicyTrafficActionAccept),
+				PortRanges:          []nmdata.RulePortRange{{Start: port, End: port}},
+			},
+		},
+	}
+}
+
+func privateAccessPolicy(svc *nmdata.Service, proxyPeer *nmdata.Peer, accessGroups []string) *nmdata.Policy {
+	policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
+
+	return &nmdata.Policy{
+		ID:       policyID,
+		PublicID: policyID,
+		Enabled:  true,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  policyID,
+				PolicyID:            policyID,
+				Enabled:             true,
+				Sources:             slices.Clone(accessGroups),
+				DestinationResource: nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)},
+				Bidirectional:       false,
+				Protocol:            string(types.PolicyRuleProtocolTCP),
+				Action:              string(types.PolicyTrafficActionAccept),
+				PortRanges: []nmdata.RulePortRange{
+					{Start: privateServicePortHTTP, End: privateServicePortHTTP},
+					{Start: privateServicePortHTTPS, End: privateServicePortHTTPS},
+				},
+			},
+		},
+	}
+}
+
+func resolveTargetPort(target *nmdata.ServiceTarget) (uint16, bool) {
+	if target.Port != 0 {
+		return target.Port, true
+	}
+
+	switch target.Protocol {
+	case "https", "tls":
+		return privateServicePortHTTPS, true
+	case "http":
+		return privateServicePortHTTP, true
+	default:
+		return 0, false
+	}
+}
+
+// proxyPeersByCluster groups the account's embedded proxy peers by the cluster
+// they serve. Sorted by peer ID so the synthesised policy order is stable.
+func (nmd *NetworkMapData) proxyPeersByCluster() map[string][]*nmdata.Peer {
+	var proxyPeers map[string][]*nmdata.Peer
+	for _, peer := range nmd.Peers {
+		if peer == nil || !peer.ProxyMeta.Embedded {
+			continue
+		}
+		if proxyPeers == nil {
+			proxyPeers = make(map[string][]*nmdata.Peer)
+		}
+		proxyPeers[peer.ProxyMeta.Cluster] = append(proxyPeers[peer.ProxyMeta.Cluster], peer)
+	}
+	for _, peers := range proxyPeers {
+		slices.SortFunc(peers, func(a, b *nmdata.Peer) int { return strings.Compare(a.ID, b.ID) })
+	}
+	return proxyPeers
+}
+
+// existingGroupIDs returns the subset of groupIDs that resolve to a group,
+// preserving the input order.
+func (nmd *NetworkMapData) existingGroupIDs(groupIDs []string) []string {
+	out := make([]string, 0, len(groupIDs))
+	for _, groupID := range groupIDs {
+		if _, ok := nmd.Groups[groupID]; ok {
+			out = append(out, groupID)
+		}
+	}
+	return out
+}
diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go
index 7d37df1de..bd3ec7120 100644
--- a/shared/management/proto/management.pb.go
+++ b/shared/management/proto/management.pb.go
@@ -5819,8 +5819,6 @@ func (x *PolicyCompact) GetSourcePostureCheckIds() []string {
 // ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry
 // rule.SourceResource / rule.DestinationResource when the rule targets a
 // specific resource (typically a peer) rather than groups.
-// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot
-// disambiguate "0" from "unset"); set only when type == "peer".
 type ResourceCompact struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -5829,6 +5827,7 @@ type ResourceCompact struct {
 	Type         string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
 	PeerIndexSet bool   `protobuf:"varint,2,opt,name=peer_index_set,json=peerIndexSet,proto3" json:"peer_index_set,omitempty"`
 	PeerIndex    uint32 `protobuf:"varint,3,opt,name=peer_index,json=peerIndex,proto3" json:"peer_index,omitempty"`
+	Id           string `protobuf:"bytes,5,opt,name=id,proto3" json:"id,omitempty"` // public id for domain/host/subnet resources
 }
 
 func (x *ResourceCompact) Reset() {
@@ -5884,6 +5883,13 @@ func (x *ResourceCompact) GetPeerIndex() uint32 {
 	return 0
 }
 
+func (x *ResourceCompact) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
 // UserNameList is a list of local-user names — used as the value type in
 // PolicyCompact.authorized_groups.
 type UserNameList struct {
@@ -5949,7 +5955,8 @@ type GroupCompact struct {
 	// groups exactly like the server does; without this bit the decoded
 	// groups lose that property and the two sides expand policy
 	// destinations differently.
-	IsAll bool `protobuf:"varint,3,opt,name=is_all,json=isAll,proto3" json:"is_all,omitempty"`
+	IsAll     bool               `protobuf:"varint,3,opt,name=is_all,json=isAll,proto3" json:"is_all,omitempty"`
+	Resources []*ResourceCompact `protobuf:"bytes,4,rep,name=resources,proto3" json:"resources,omitempty"`
 }
 
 func (x *GroupCompact) Reset() {
@@ -6005,6 +6012,13 @@ func (x *GroupCompact) GetIsAll() bool {
 	return false
 }
 
+func (x *GroupCompact) GetResources() []*ResourceCompact {
+	if x != nil {
+		return x.Resources
+	}
+	return nil
+}
+
 // DNSSettingsCompact mirrors types.DNSSettings.
 type DNSSettingsCompact struct {
 	state         protoimpl.MessageState
@@ -7726,216 +7740,221 @@ var file_management_proto_rawDesc = []byte{
 	0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61,
 	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d,
 	0x65, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01,
-	0x22, 0x70, 0x0a, 0x0f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, 0x70,
-	0x61, 0x63, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f,
-	0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
-	0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x1d, 0x0a,
-	0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28,
-	0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x4a, 0x04, 0x08, 0x04,
-	0x10, 0x05, 0x22, 0x24, 0x0a, 0x0c, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c, 0x69,
-	0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
-	0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x58, 0x0a, 0x0c, 0x47, 0x72, 0x6f, 0x75,
-	0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x65, 0x65, 0x72,
-	0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0b,
-	0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x15, 0x0a, 0x06, 0x69,
-	0x73, 0x5f, 0x61, 0x6c, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x69, 0x73, 0x41,
-	0x6c, 0x6c, 0x22, 0x57, 0x0a, 0x12, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67,
-	0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x64, 0x69, 0x73, 0x61,
-	0x62, 0x6c, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f,
-	0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
-	0x1a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
-	0x65, 0x6e, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x8d, 0x04, 0x0a, 0x08,
-	0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6e, 0x65, 0x74, 0x5f,
-	0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x64, 0x12,
-	0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
-	0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x63, 0x69, 0x64,
-	0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
-	0x43, 0x69, 0x64, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18,
-	0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1d,
-	0x0a, 0x0a, 0x6b, 0x65, 0x65, 0x70, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01,
-	0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a,
-	0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18,
-	0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78,
-	0x53, 0x65, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65,
-	0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64,
-	0x65, 0x78, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70,
-	0x5f, 0x69, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72,
-	0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77,
-	0x6f, 0x72, 0x6b, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b,
-	0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x6d,
-	0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52,
-	0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d,
-	0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74,
-	0x72, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d,
-	0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1b, 0x0a,
-	0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09,
-	0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x61, 0x63,
-	0x63, 0x65, 0x73, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x67, 0x72, 0x6f,
-	0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x61, 0x63,
-	0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x47, 0x72, 0x6f, 0x75, 0x70,
-	0x49, 0x64, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f,
-	0x5f, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b,
-	0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x12,
-	0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52,
-	0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,
-	0x69, 0x64, 0x12, 0x38, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
-	0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
-	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52,
-	0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x1b, 0x0a, 0x09,
-	0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52,
-	0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69,
-	0x6d, 0x61, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x6d,
-	0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05,
-	0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x18, 0x0a,
-	0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
-	0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x16, 0x73, 0x65, 0x61, 0x72, 0x63,
-	0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65,
-	0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44,
-	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x87, 0x02,
-	0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
-	0x65, 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x02, 0x69, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f,
-	0x73, 0x65, 0x71, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f,
-	0x72, 0x6b, 0x53, 0x65, 0x71, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73,
-	0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b,
-	0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74,
-	0x79, 0x70, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12,
-	0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x6f, 0x6d,
-	0x61, 0x69, 0x6e, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0b, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b,
-	0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x0a, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x43, 0x69, 0x64, 0x72, 0x12, 0x18, 0x0a,
-	0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
-	0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x4d, 0x0a, 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f,
-	0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x38, 0x0a, 0x07,
-	0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e,
-	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
-	0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x65,
-	0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x22, 0xe1, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f,
-	0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x0e, 0x0a,
-	0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a,
-	0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28,
-	0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x24, 0x0a, 0x0e,
-	0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x03,
-	0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53,
-	0x65, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70,
-	0x5f, 0x69, 0x64, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72,
-	0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71,
-	0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61,
+	0x22, 0x80, 0x01, 0x0a, 0x0f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d,
+	0x70, 0x61, 0x63, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72,
+	0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x1d,
+	0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x0e, 0x0a,
+	0x02, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x4a, 0x04, 0x08,
+	0x04, 0x10, 0x05, 0x22, 0x24, 0x0a, 0x0c, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c,
+	0x69, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03,
+	0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x93, 0x01, 0x0a, 0x0c, 0x47, 0x72,
+	0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x65,
+	0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d,
+	0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x15, 0x0a,
+	0x06, 0x69, 0x73, 0x5f, 0x61, 0x6c, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x69,
+	0x73, 0x41, 0x6c, 0x6c, 0x12, 0x39, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
+	0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d,
+	0x70, 0x61, 0x63, 0x74, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x22,
+	0x57, 0x0a, 0x12, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f,
+	0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
+	0x64, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x67, 0x72, 0x6f,
+	0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1a, 0x64, 0x69,
+	0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x8d, 0x04, 0x0a, 0x08, 0x52, 0x6f, 0x75,
+	0x74, 0x65, 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0b,
+	0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21,
+	0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x04,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x69, 0x64,
+	0x72, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03,
+	0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6b,
+	0x65, 0x65, 0x70, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65,
+	0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74,
+	0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x08,
+	0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12,
+	0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64,
+	0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f,
+	0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
+	0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x6e, 0x65, 0x74,
+	0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71,
+	0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61,
 	0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72,
-	0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63,
-	0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28,
-	0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x1d, 0x0a, 0x09, 0x50, 0x6f,
-	0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01,
-	0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x27, 0x0a, 0x0a, 0x55, 0x73, 0x65,
-	0x72, 0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x5f,
-	0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x75, 0x73, 0x65, 0x72, 0x49,
-	0x64, 0x73, 0x22, 0x31, 0x0a, 0x0c, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53,
-	0x65, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78,
-	0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e,
-	0x64, 0x65, 0x78, 0x65, 0x73, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74,
-	0x75, 0x73, 0x12, 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74,
-	0x61, 0x74, 0x75, 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65,
-	0x64, 0x65, 0x64, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10,
-	0x02, 0x2a, 0x93, 0x01, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69,
-	0x6c, 0x69, 0x74, 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61,
-	0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12,
-	0x20, 0x0a, 0x1c, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
-	0x79, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10,
-	0x01, 0x12, 0x1d, 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c,
-	0x69, 0x74, 0x79, 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02,
-	0x12, 0x25, 0x0a, 0x21, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69,
-	0x74, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f,
-	0x72, 0x6b, 0x4d, 0x61, 0x70, 0x10, 0x03, 0x2a, 0x48, 0x0a, 0x09, 0x4c, 0x61, 0x7a, 0x79, 0x53,
-	0x74, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x10, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74,
-	0x65, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x4c, 0x61,
-	0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x4c, 0x61, 0x7a, 0x79, 0x10, 0x01, 0x12, 0x12, 0x0a,
-	0x0e, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x45, 0x61, 0x67, 0x65, 0x72, 0x10,
-	0x02, 0x2a, 0x5d, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
-	0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07,
-	0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02,
-	0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d,
-	0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x12,
-	0x0f, 0x0a, 0x0b, 0x4e, 0x45, 0x54, 0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10, 0x06,
-	0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f,
-	0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54,
-	0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e,
-	0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04,
-	0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
-	0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f,
-	0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50,
-	0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45,
-	0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45,
-	0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45,
-	0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, 0x11,
-	0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
-	0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e,
-	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
-	0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
-	0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63,
-	0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
-	0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
-	0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01,
-	0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79,
-	0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d,
-	0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
-	0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
-	0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68,
-	0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
-	0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74,
-	0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74,
-	0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x69, 0x63, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63,
+	0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72,
+	0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67,
+	0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x61, 0x63, 0x63, 0x65, 0x73,
+	0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f,
+	0x69, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x61, 0x63, 0x63, 0x65, 0x73,
+	0x73, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73,
+	0x12, 0x26, 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x70,
+	0x70, 0x6c, 0x79, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41,
+	0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x12, 0x4e, 0x61, 0x6d,
+	0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x12,
+	0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12,
+	0x38, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x02,
+	0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x6e, 0x61,
+	0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f,
+	0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72,
+	0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72,
+	0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79,
+	0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28,
+	0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e,
+	0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61,
+	0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x16, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f, 0x64,
+	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07,
+	0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61,
+	0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x87, 0x02, 0x0a, 0x12, 0x4e,
+	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61,
+	0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
+	0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x73, 0x65, 0x71,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53,
+	0x65, 0x71, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69,
+	0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73,
+	0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65,
+	0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07,
+	0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61,
+	0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
+	0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x6f,
+	0x6d, 0x61, 0x69, 0x6e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x65,
+	0x66, 0x69, 0x78, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a,
+	0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x43, 0x69, 0x64, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e,
+	0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61,
+	0x62, 0x6c, 0x65, 0x64, 0x22, 0x4d, 0x0a, 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52,
+	0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x38, 0x0a, 0x07, 0x65, 0x6e, 0x74,
+	0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52,
+	0x6f, 0x75, 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x65, 0x6e, 0x74, 0x72,
+	0x69, 0x65, 0x73, 0x22, 0xe1, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52,
+	0x6f, 0x75, 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65,
+	0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09,
+	0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65,
+	0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12,
+	0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64,
+	0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f,
+	0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72,
+	0x61, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75,
+	0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18,
+	0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x18, 0x0a,
+	0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
+	0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x1d, 0x0a, 0x09, 0x50, 0x6f, 0x6c, 0x69, 0x63,
+	0x79, 0x49, 0x64, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
+	0x09, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x27, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44,
+	0x4c, 0x69, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73,
+	0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x22,
+	0x31, 0x0a, 0x0c, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12,
+	0x21, 0x0a, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18,
+	0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78,
+	0x65, 0x73, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12,
+	0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75,
+	0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64,
+	0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x93,
+	0x01, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
+	0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c,
+	0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c,
+	0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f,
+	0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d,
+	0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79,
+	0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x12, 0x25, 0x0a,
+	0x21, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x43,
+	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d,
+	0x61, 0x70, 0x10, 0x03, 0x2a, 0x48, 0x0a, 0x09, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74,
+	0x65, 0x12, 0x14, 0x0a, 0x10, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x44, 0x65,
+	0x66, 0x61, 0x75, 0x6c, 0x74, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x4c, 0x61, 0x7a, 0x79, 0x53,
+	0x74, 0x61, 0x74, 0x65, 0x4c, 0x61, 0x7a, 0x79, 0x10, 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x4c, 0x61,
+	0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x45, 0x61, 0x67, 0x65, 0x72, 0x10, 0x02, 0x2a, 0x5d,
+	0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b,
+	0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41,
+	0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a,
+	0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04,
+	0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x12, 0x0f, 0x0a, 0x0b,
+	0x4e, 0x45, 0x54, 0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10, 0x06, 0x2a, 0x20, 0x0a,
+	0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06,
+	0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a,
+	0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a,
+	0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f,
+	0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f,
+	0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f,
+	0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45,
+	0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f,
+	0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f,
+	0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f,
+	0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, 0x11, 0x4d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45,
+	0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
 	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
 	0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
 	0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
-	0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45,
-	0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f,
-	0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
-	0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a,
-	0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
-	0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12,
-	0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61,
+	0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79,
+	0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61,
 	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
-	0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b,
-	0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a,
+	0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
+	0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65,
+	0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
+	0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74,
+	0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
+	0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76,
+	0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
+	0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+	0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
+	0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74,
+	0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
+	0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
+	0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08,
+	0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
 	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
 	0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
-	0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a,
-	0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
-	0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
-	0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00,
-	0x28, 0x01, 0x30, 0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75,
-	0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64,
-	0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
-	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
-	0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74,
-	0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
-	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
-	0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+	0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c,
+	0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
 	0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
-	0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78,
+	0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12,
+	0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
+	0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79,
+	0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30,
+	0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53,
+	0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+	0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73,
+	0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
+	0x67, 0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78,
 	0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
 	0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
 	0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
 	0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
-	0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
-	0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
-	0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
-	0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08,
-	0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73,
+	0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
+	0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a,
+	0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
+	0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12,
+	0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79,
+	0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61,
+	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
+	0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -8149,45 +8168,46 @@ var file_management_proto_depIdxs = []int32{
 	91,  // 97: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry
 	73,  // 98: management.PolicyCompact.source_resource:type_name -> management.ResourceCompact
 	73,  // 99: management.PolicyCompact.destination_resource:type_name -> management.ResourceCompact
-	52,  // 100: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer
-	81,  // 101: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry
-	39,  // 102: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
-	80,  // 103: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList
-	82,  // 104: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds
-	83,  // 105: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList
-	84,  // 106: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet
-	74,  // 107: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList
-	9,   // 108: management.ManagementService.Login:input_type -> management.EncryptedMessage
-	9,   // 109: management.ManagementService.Sync:input_type -> management.EncryptedMessage
-	27,  // 110: management.ManagementService.GetServerKey:input_type -> management.Empty
-	27,  // 111: management.ManagementService.isHealthy:input_type -> management.Empty
-	9,   // 112: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
-	9,   // 113: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
-	9,   // 114: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
-	9,   // 115: management.ManagementService.Logout:input_type -> management.EncryptedMessage
-	9,   // 116: management.ManagementService.Job:input_type -> management.EncryptedMessage
-	9,   // 117: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
-	9,   // 118: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
-	9,   // 119: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
-	9,   // 120: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
-	9,   // 121: management.ManagementService.Login:output_type -> management.EncryptedMessage
-	9,   // 122: management.ManagementService.Sync:output_type -> management.EncryptedMessage
-	26,  // 123: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
-	27,  // 124: management.ManagementService.isHealthy:output_type -> management.Empty
-	9,   // 125: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
-	9,   // 126: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
-	27,  // 127: management.ManagementService.SyncMeta:output_type -> management.Empty
-	27,  // 128: management.ManagementService.Logout:output_type -> management.Empty
-	9,   // 129: management.ManagementService.Job:output_type -> management.EncryptedMessage
-	9,   // 130: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
-	9,   // 131: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
-	9,   // 132: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
-	9,   // 133: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
-	121, // [121:134] is the sub-list for method output_type
-	108, // [108:121] is the sub-list for method input_type
-	108, // [108:108] is the sub-list for extension type_name
-	108, // [108:108] is the sub-list for extension extendee
-	0,   // [0:108] is the sub-list for field type_name
+	73,  // 100: management.GroupCompact.resources:type_name -> management.ResourceCompact
+	52,  // 101: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer
+	81,  // 102: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry
+	39,  // 103: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
+	80,  // 104: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList
+	82,  // 105: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds
+	83,  // 106: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList
+	84,  // 107: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet
+	74,  // 108: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList
+	9,   // 109: management.ManagementService.Login:input_type -> management.EncryptedMessage
+	9,   // 110: management.ManagementService.Sync:input_type -> management.EncryptedMessage
+	27,  // 111: management.ManagementService.GetServerKey:input_type -> management.Empty
+	27,  // 112: management.ManagementService.isHealthy:input_type -> management.Empty
+	9,   // 113: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
+	9,   // 114: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
+	9,   // 115: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
+	9,   // 116: management.ManagementService.Logout:input_type -> management.EncryptedMessage
+	9,   // 117: management.ManagementService.Job:input_type -> management.EncryptedMessage
+	9,   // 118: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
+	9,   // 119: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
+	9,   // 120: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
+	9,   // 121: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
+	9,   // 122: management.ManagementService.Login:output_type -> management.EncryptedMessage
+	9,   // 123: management.ManagementService.Sync:output_type -> management.EncryptedMessage
+	26,  // 124: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
+	27,  // 125: management.ManagementService.isHealthy:output_type -> management.Empty
+	9,   // 126: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
+	9,   // 127: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
+	27,  // 128: management.ManagementService.SyncMeta:output_type -> management.Empty
+	27,  // 129: management.ManagementService.Logout:output_type -> management.Empty
+	9,   // 130: management.ManagementService.Job:output_type -> management.EncryptedMessage
+	9,   // 131: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
+	9,   // 132: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
+	9,   // 133: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
+	9,   // 134: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
+	122, // [122:135] is the sub-list for method output_type
+	109, // [109:122] is the sub-list for method input_type
+	109, // [109:109] is the sub-list for extension type_name
+	109, // [109:109] is the sub-list for extension extendee
+	0,   // [0:109] is the sub-list for field type_name
 }
 
 func init() { file_management_proto_init() }
diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto
index 355fc1ed7..24ff5bf37 100644
--- a/shared/management/proto/management.proto
+++ b/shared/management/proto/management.proto
@@ -1094,13 +1094,12 @@ message PolicyCompact {
 // ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry
 // rule.SourceResource / rule.DestinationResource when the rule targets a
 // specific resource (typically a peer) rather than groups.
-// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot
-// disambiguate "0" from "unset"); set only when type == "peer".
 message ResourceCompact {
   string type = 1;
   bool peer_index_set = 2;
   uint32 peer_index = 3;
-  reserved 4; // future: host/subnet/domain references when needed
+  reserved 4;
+  string id = 5; // public id for domain/host/subnet resources
 }
 
 // UserNameList is a list of local-user names — used as the value type in
@@ -1124,6 +1123,8 @@ message GroupCompact {
   // groups lose that property and the two sides expand policy
   // destinations differently.
   bool is_all = 3;
+
+  repeated ResourceCompact resources = 4;
 }
 
 // DNSSettingsCompact mirrors types.DNSSettings.
diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go
index 6e43af33e..9357d24a9 100644
--- a/shared/management/types/firewall_helpers.go
+++ b/shared/management/types/firewall_helpers.go
@@ -3,6 +3,7 @@ package types
 import (
 	"strconv"
 
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/version"
 )
 
@@ -23,31 +24,9 @@ type supportedFeatures struct {
 
 type LookupMap map[string]struct{}
 
-func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
-	return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
-}
-
-func portRangeIncludesSSH(portRanges []RulePortRange) bool {
-	for _, pr := range portRanges {
-		if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
-			return true
-		}
-	}
-	return false
-}
-
-func portsIncludesSSH(ports []string) bool {
-	for _, port := range ports {
-		if port == defaultSSHPortString || port == nativeSSHPortString {
-			return true
-		}
-	}
-	return false
-}
-
 // ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules.
-func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule {
-	features := peerSupportedFirewallFeatures(peer.AgentVersion)
+func ExpandPortsAndRanges(base FirewallRule, rule *nmdata.PolicyRule, peer *nmdata.Peer) []*FirewallRule {
+	features := peerSupportedFirewallFeatures(peer.Meta.WtVersion)
 
 	var expanded []*FirewallRule
 
@@ -64,7 +43,7 @@ func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPe
 		fr := base
 
 		if features.portRanges {
-			fr.PortRange = portRange
+			fr.PortRange = RulePortRange{Start: portRange.Start, End: portRange.End}
 		} else {
 			if portRange.Start != portRange.End {
 				continue
@@ -74,7 +53,7 @@ func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPe
 		expanded = append(expanded, &fr)
 	}
 
-	if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH {
+	if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == string(PolicyRuleProtocolNetbirdSSH) {
 		expanded = addNativeSSHRule(base, expanded)
 	}
 
@@ -104,8 +83,8 @@ func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool {
 	return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End)
 }
 
-func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool {
-	return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP
+func shouldCheckRulesForNativeSSH(supportsNative bool, rule *nmdata.PolicyRule, peer *nmdata.Peer) bool {
+	return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == string(PolicyRuleProtocolTCP)
 }
 
 func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
diff --git a/shared/management/types/firewall_rule.go b/shared/management/types/firewall_rule.go
index 67cb581a2..2efedf625 100644
--- a/shared/management/types/firewall_rule.go
+++ b/shared/management/types/firewall_rule.go
@@ -10,6 +10,7 @@ import (
 	log "github.com/sirupsen/logrus"
 
 	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 const (
@@ -50,7 +51,7 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool {
 // For static routes, source ranges match the destination family (v4 or v6).
 // For dynamic routes (domain-based), separate v4 and v6 rules are generated
 // so the routing peer's forwarding chain allows both address families.
-func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule {
+func GenerateRouteFirewallRules(ctx context.Context, route *nmdata.Route, rule *nmdata.PolicyRule, groupPeers []*nmdata.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule {
 	rulesExists := make(map[string]struct{})
 	rules := make([]*RouteFirewallRule, 0)
 
@@ -71,11 +72,11 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule
 
 	baseRule := RouteFirewallRule{
 		PolicyID:     rule.PolicyID,
-		RouteID:      route.ID,
+		RouteID:      nbroute.ID(route.ID),
 		SourceRanges: sourceRanges,
-		Action:       string(rule.Action),
+		Action:       rule.Action,
 		Destination:  route.Network.String(),
-		Protocol:     string(rule.Protocol),
+		Protocol:     rule.Protocol,
 		Domains:      route.Domains,
 		IsDynamic:    route.IsDynamic(),
 	}
@@ -93,7 +94,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule
 		v6Rule.SourceRanges = v6Sources
 		if isDefaultV4 {
 			v6Rule.Destination = "::/0"
-			v6Rule.RouteID = route.ID + "-v6-default"
+			v6Rule.RouteID = nbroute.ID(route.ID + "-v6-default")
 		}
 		if len(rule.Ports) == 0 {
 			rules = append(rules, generateRulesWithPortRanges(v6Rule, rule, rulesExists)...)
@@ -106,7 +107,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule
 }
 
 // splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges.
-func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) {
+func splitPeerSourcesByFamily(groupPeers []*nmdata.Peer) (v4, v6 []string) {
 	v4 = make([]string, 0, len(groupPeers))
 	v6 = make([]string, 0, len(groupPeers))
 	for _, peer := range groupPeers {
@@ -122,7 +123,7 @@ func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) {
 }
 
 // generateRulesForPeer generates rules for a given peer based on ports and port ranges.
-func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
+func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *nmdata.PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
 	rules := make([]*RouteFirewallRule, 0)
 
 	ruleIDBase := generateRuleIDBase(rule, baseRule)
@@ -138,7 +139,7 @@ func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, r
 				if _, ok := rulesExists[ruleID]; !ok {
 					rulesExists[ruleID] = struct{}{}
 					pr := baseRule
-					pr.PortRange = portRange
+					pr.PortRange = RulePortRange{Start: portRange.Start, End: portRange.End}
 					rules = append(rules, &pr)
 				}
 			}
@@ -150,7 +151,7 @@ func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, r
 }
 
 // generateRulesWithPorts generates rules when specific ports are provided.
-func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
+func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *nmdata.PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
 	rules := make([]*RouteFirewallRule, 0)
 	ruleIDBase := generateRuleIDBase(rule, baseRule)
 
@@ -176,6 +177,6 @@ func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rul
 }
 
 // generateRuleIDBase generates the base rule ID for checking duplicates.
-func generateRuleIDBase(rule *PolicyRule, baseRule RouteFirewallRule) string {
+func generateRuleIDBase(rule *nmdata.PolicyRule, baseRule RouteFirewallRule) string {
 	return rule.ID + strings.Join(baseRule.SourceRanges, ",") + strconv.Itoa(FirewallRuleDirectionIN) + baseRule.Protocol + baseRule.Action
 }
diff --git a/shared/management/types/firewall_rule_test.go b/shared/management/types/firewall_rule_test.go
index c21cfa2df..96fef3bd9 100644
--- a/shared/management/types/firewall_rule_test.go
+++ b/shared/management/types/firewall_rule_test.go
@@ -8,12 +8,12 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func TestSplitPeerSourcesByFamily(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -35,7 +35,7 @@ func TestSplitPeerSourcesByFamily(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_V4Route(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -45,15 +45,15 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) {
 		},
 	}
 
-	r := &route.Route{
+	r := &nmdata.Route{
 		ID:      "route1",
 		Network: netip.MustParsePrefix("10.0.0.0/24"),
 	}
-	rule := &PolicyRule{
+	rule := &nmdata.PolicyRule{
 		PolicyID: "policy1",
 		ID:       "rule1",
-		Action:   PolicyTrafficActionAccept,
-		Protocol: PolicyRuleProtocolALL,
+		Action:   string(PolicyTrafficActionAccept),
+		Protocol: string(PolicyRuleProtocolALL),
 	}
 
 	rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
@@ -64,7 +64,7 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_V6Route(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -74,15 +74,15 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) {
 		},
 	}
 
-	r := &route.Route{
+	r := &nmdata.Route{
 		ID:      "route1",
 		Network: netip.MustParsePrefix("2001:db8::/32"),
 	}
-	rule := &PolicyRule{
+	rule := &nmdata.PolicyRule{
 		PolicyID: "policy1",
 		ID:       "rule1",
-		Action:   PolicyTrafficActionAccept,
-		Protocol: PolicyRuleProtocolALL,
+		Action:   string(PolicyTrafficActionAccept),
+		Protocol: string(PolicyRuleProtocolALL),
 	}
 
 	rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
@@ -92,7 +92,7 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -102,16 +102,16 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) {
 		},
 	}
 
-	r := &route.Route{
+	r := &nmdata.Route{
 		ID:          "route1",
-		NetworkType: route.DomainNetwork,
+		NetworkType: nmdata.NetworkTypeDomain,
 		Domains:     domain.List{"example.com"},
 	}
-	rule := &PolicyRule{
+	rule := &nmdata.PolicyRule{
 		PolicyID: "policy1",
 		ID:       "rule1",
-		Action:   PolicyTrafficActionAccept,
-		Protocol: PolicyRuleProtocolALL,
+		Action:   string(PolicyTrafficActionAccept),
+		Protocol: string(PolicyRuleProtocolALL),
 	}
 
 	rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
@@ -125,21 +125,21 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{IP: netip.MustParseAddr("100.64.0.1")},
 		{IP: netip.MustParseAddr("100.64.0.2")},
 	}
 
-	r := &route.Route{
+	r := &nmdata.Route{
 		ID:          "route1",
-		NetworkType: route.DomainNetwork,
+		NetworkType: nmdata.NetworkTypeDomain,
 		Domains:     domain.List{"example.com"},
 	}
-	rule := &PolicyRule{
+	rule := &nmdata.PolicyRule{
 		PolicyID: "policy1",
 		ID:       "rule1",
-		Action:   PolicyTrafficActionAccept,
-		Protocol: PolicyRuleProtocolALL,
+		Action:   string(PolicyTrafficActionAccept),
+		Protocol: string(PolicyRuleProtocolALL),
 	}
 
 	rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
@@ -149,7 +149,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -161,15 +161,15 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) {
 	}
 
 	t.Run("v6 route excluded", func(t *testing.T) {
-		r := &route.Route{
+		r := &nmdata.Route{
 			ID:      "route1",
 			Network: netip.MustParsePrefix("2001:db8::/32"),
 		}
-		rule := &PolicyRule{
+		rule := &nmdata.PolicyRule{
 			PolicyID: "policy1",
 			ID:       "rule1",
-			Action:   PolicyTrafficActionAccept,
-			Protocol: PolicyRuleProtocolALL,
+			Action:   string(PolicyTrafficActionAccept),
+			Protocol: string(PolicyRuleProtocolALL),
 		}
 
 		rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false)
@@ -177,16 +177,16 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) {
 	})
 
 	t.Run("dynamic route only v4", func(t *testing.T) {
-		r := &route.Route{
+		r := &nmdata.Route{
 			ID:          "route1",
-			NetworkType: route.DomainNetwork,
+			NetworkType: nmdata.NetworkTypeDomain,
 			Domains:     domain.List{"example.com"},
 		}
-		rule := &PolicyRule{
+		rule := &nmdata.PolicyRule{
 			PolicyID: "policy1",
 			ID:       "rule1",
-			Action:   PolicyTrafficActionAccept,
-			Protocol: PolicyRuleProtocolALL,
+			Action:   string(PolicyTrafficActionAccept),
+			Protocol: string(PolicyRuleProtocolALL),
 		}
 
 		rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false)
diff --git a/shared/management/types/network.go b/shared/management/types/network.go
index 34ce60436..1269bac4c 100644
--- a/shared/management/types/network.go
+++ b/shared/management/types/network.go
@@ -1,47 +1,28 @@
 package types
 
 import (
-	"encoding/binary"
-	"fmt"
-	"math/rand"
 	"net"
-	"net/netip"
-	"slices"
-	"sync"
-	"time"
 
-	"github.com/c-robinson/iplib"
-	"github.com/rs/xid"
 	"golang.org/x/exp/maps"
 
 	nbdns "github.com/netbirdio/netbird/dns"
-	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
-	"github.com/netbirdio/netbird/shared/management/status"
 )
 
 const (
-	// SubnetSize is a size of the subnet of the global network, e.g.  100.77.0.0/16
-	SubnetSize = 16
-	// NetSize is a global network size 100.64.0.0/10
-	NetSize = 10
-
 	// AllowedIPsFormat generates Wireguard AllowedIPs format (e.g. 100.64.30.1/32)
 	AllowedIPsFormat = "%s/32"
 	// AllowedIPsV6Format generates AllowedIPs format for v6 (e.g. fd12:3456:7890::1/128)
 	AllowedIPsV6Format = "%s/128"
-
-	// IPv6SubnetSize is the prefix length of per-account IPv6 subnets.
-	// Each account gets a /64 from its unique /48 ULA prefix.
-	IPv6SubnetSize = 64
 )
 
 type NetworkMap struct {
-	Peers               []*ComponentPeer
-	Network             *Network
-	Routes              []*route.Route
+	Peers               []*nmdata.Peer
+	Network             *nmdata.Network
+	Routes              []*nmdata.Route
 	DNSConfig           nbdns.Config
-	OfflinePeers        []*ComponentPeer
+	OfflinePeers        []*nmdata.Peer
 	FirewallRules       []*FirewallRule
 	RoutesFirewallRules []*RouteFirewallRule
 	ForwardingRules     []*ForwardingRule
@@ -63,39 +44,8 @@ func (nm *NetworkMap) Merge(other *NetworkMap) {
 	nm.ForceRoutingPeerDNSResolution = nm.ForceRoutingPeerDNSResolution || other.ForceRoutingPeerDNSResolution
 }
 
-type comparableObject[T any] interface {
-	Equal(other T) bool
-}
-
-func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T {
-	var result []T
-
-	for _, item := range arr1 {
-		if !containsEqual(result, item) {
-			result = append(result, item)
-		}
-	}
-
-	for _, item := range arr2 {
-		if !containsEqual(result, item) {
-			result = append(result, item)
-		}
-	}
-
-	return result
-}
-
-func containsEqual[T comparableObject[T]](slice []T, element T) bool {
-	for _, item := range slice {
-		if item.Equal(element) {
-			return true
-		}
-	}
-	return false
-}
-
-func mergeUniquePeersByID(peers1, peers2 []*ComponentPeer) []*ComponentPeer {
-	result := make(map[string]*ComponentPeer)
+func mergeUniquePeersByID(peers1, peers2 []*nmdata.Peer) []*nmdata.Peer {
+	result := make(map[string]*nmdata.Peer)
 	for _, peer := range peers1 {
 		result[peer.ID] = peer
 	}
@@ -151,245 +101,33 @@ func ipToBytes(ip net.IP) []byte {
 	return ip.To16()
 }
 
-type Network struct {
-	Identifier string    `json:"id"`
-	Net        net.IPNet `gorm:"serializer:json"`
-	// NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated.
-	NetV6 net.IPNet `gorm:"serializer:json"`
-	Dns   string
-	// Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added).
-	// Used to synchronize state to the client apps.
-	Serial uint64
-
-	Mu sync.Mutex `json:"-" gorm:"-"`
+type comparableObject[T any] interface {
+	Equal(other T) bool
 }
 
-// NewNetwork creates a new Network initializing it with a Serial=0
-// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets)
-// and a random /64 subnet from fd00:4e42::/32 for IPv6.
-func NewNetwork() *Network {
-	n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
-	sub, _ := n.Subnet(SubnetSize)
+func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T {
+	var result []T
 
-	s := rand.NewSource(time.Now().UnixNano())
-	r := rand.New(s)
-	intn := r.Intn(len(sub))
-
-	return &Network{
-		Identifier: xid.New().String(),
-		Net:        sub[intn].IPNet,
-		NetV6:      AllocateIPv6Subnet(r),
-		Dns:        "",
-		Serial:     0,
-	}
-}
-
-// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix.
-// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
-// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
-// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
-func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
-	ip := make(net.IP, 16)
-	ip[0] = 0xfd
-	// Bytes 1-5: 40-bit random Global ID
-	ip[1] = byte(r.Intn(256))
-	ip[2] = byte(r.Intn(256))
-	ip[3] = byte(r.Intn(256))
-	ip[4] = byte(r.Intn(256))
-	ip[5] = byte(r.Intn(256))
-	// Bytes 6-7: 16-bit random Subnet ID
-	ip[6] = byte(r.Intn(256))
-	ip[7] = byte(r.Intn(256))
-
-	return net.IPNet{
-		IP:   ip,
-		Mask: net.CIDRMask(IPv6SubnetSize, 128),
-	}
-}
-
-// IncSerial increments Serial by 1 reflecting that the network state has been changed
-func (n *Network) IncSerial() {
-	n.Mu.Lock()
-	defer n.Mu.Unlock()
-	n.Serial++
-}
-
-// CurrentSerial returns the Network.Serial of the network (latest state id)
-func (n *Network) CurrentSerial() uint64 {
-	n.Mu.Lock()
-	defer n.Mu.Unlock()
-	return n.Serial
-}
-
-func (n *Network) Copy() *Network {
-	n.Mu.Lock()
-	defer n.Mu.Unlock()
-	return &Network{
-		Identifier: n.Identifier,
-		Net:        n.Net,
-		NetV6:      n.NetV6,
-		Dns:        n.Dns,
-		Serial:     n.Serial,
-	}
-}
-
-// AllocatePeerIP picks an available IP from a netip.Prefix.
-// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
-// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
-func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
-	b := prefix.Masked().Addr().As4()
-	baseIP := binary.BigEndian.Uint32(b[:])
-	hostBits := 32 - prefix.Bits()
-	totalIPs := uint32(1 << hostBits)
-
-	taken := make(map[uint32]struct{}, len(takenIps)+1)
-	taken[baseIP] = struct{}{}            // reserve network IP
-	taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
-
-	for _, ip := range takenIps {
-		ab := ip.As4()
-		taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
-	}
-
-	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
-	maxAttempts := (int(totalIPs) - len(taken)) / 100
-
-	for i := 0; i < maxAttempts; i++ {
-		offset := uint32(rng.Intn(int(totalIPs-2))) + 1
-		candidate := baseIP + offset
-		if _, exists := taken[candidate]; !exists {
-			return uint32ToIP(candidate), nil
+	for _, item := range arr1 {
+		if !containsEqual(result, item) {
+			result = append(result, item)
 		}
 	}
 
-	for offset := uint32(1); offset < totalIPs-1; offset++ {
-		candidate := baseIP + offset
-		if _, exists := taken[candidate]; !exists {
-			return uint32ToIP(candidate), nil
+	for _, item := range arr2 {
+		if !containsEqual(result, item) {
+			result = append(result, item)
 		}
 	}
 
-	return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String())
+	return result
 }
 
-// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
-func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
-	b := prefix.Masked().Addr().As4()
-	baseIP := binary.BigEndian.Uint32(b[:])
-	hostBits := 32 - prefix.Bits()
-	totalIPs := uint32(1 << hostBits)
-
-	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
-	offset := uint32(rng.Intn(int(totalIPs-2))) + 1
-
-	candidate := baseIP + offset
-	return uint32ToIP(candidate), nil
-}
-
-// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix.
-// Only the host bits (after the prefix length) are randomized.
-func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
-	ones := prefix.Bits()
-	if ones == 0 || ones > 126 || !prefix.Addr().Is6() {
-		return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String())
-	}
-
-	ip := prefix.Addr().As16()
-
-	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
-
-	// Determine which byte the host bits start in
-	firstHostByte := ones / 8
-	// If the prefix doesn't end on a byte boundary, handle the partial byte
-	partialBits := ones % 8
-
-	if partialBits > 0 {
-		// Keep the network bits in the partial byte, randomize the rest
-		hostMask := byte(0xff >> partialBits)
-		ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
-		firstHostByte++
-	}
-
-	// Randomize remaining full host bytes
-	for i := firstHostByte; i < 16; i++ {
-		ip[i] = byte(rng.Intn(256))
-	}
-
-	// Avoid all-zeros and all-ones host parts by checking only host bits.
-	if isHostAllZeroOrOnes(ip[:], ones) {
-		ip = prefix.Masked().Addr().As16()
-		ip[15] |= 0x01
-	}
-
-	return netip.AddrFrom16(ip).Unmap(), nil
-}
-
-// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones.
-func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool {
-	hostStart := prefixLen / 8
-	partialBits := prefixLen % 8
-
-	hostSlice := slices.Clone(ip[hostStart:])
-	if partialBits > 0 {
-		hostSlice[0] &= 0xff >> partialBits
-	}
-
-	allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 })
-	if allZero {
-		return true
-	}
-
-	// Build the all-ones mask for host bits
-	onesMask := make([]byte, len(hostSlice))
-	for i := range onesMask {
-		onesMask[i] = 0xff
-	}
-	if partialBits > 0 {
-		onesMask[0] = 0xff >> partialBits
-	}
-
-	return slices.Equal(hostSlice, onesMask)
-}
-
-func uint32ToIP(n uint32) netip.Addr {
-	var b [4]byte
-	binary.BigEndian.PutUint32(b[:], n)
-	return netip.AddrFrom4(b)
-}
-
-// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list
-func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) {
-
-	var ips []net.IP
-	for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) {
-		if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 {
-			ips = append(ips, copyIP(ip))
-		}
-	}
-
-	// remove network address, broadcast and Fake DNS resolver address
-	lenIPs := len(ips)
-	switch {
-	case lenIPs < 2:
-		return ips, lenIPs
-	case lenIPs < 3:
-		return ips[1 : len(ips)-1], lenIPs - 2
-	default:
-		return ips[1 : len(ips)-2], lenIPs - 3
-	}
-}
-
-func copyIP(ip net.IP) net.IP {
-	dup := make(net.IP, len(ip))
-	copy(dup, ip)
-	return dup
-}
-
-func incIP(ip net.IP) {
-	for j := len(ip) - 1; j >= 0; j-- {
-		ip[j]++
-		if ip[j] > 0 {
-			break
+func containsEqual[T comparableObject[T]](slice []T, element T) bool {
+	for _, item := range slice {
+		if item.Equal(element) {
+			return true
 		}
 	}
+	return false
 }
diff --git a/shared/management/types/network_merge_test.go b/shared/management/types/network_merge_test.go
deleted file mode 100644
index a7ef24c1e..000000000
--- a/shared/management/types/network_merge_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package types
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-type testObject struct {
-	value int
-}
-
-func (t testObject) Equal(other testObject) bool {
-	return t.value == other.value
-}
-
-func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) {
-	arr1 := []testObject{{value: 1}, {value: 2}}
-	arr2 := []testObject{{value: 2}, {value: 3}}
-	result := mergeUnique(arr1, arr2)
-	assert.Len(t, result, 3)
-	assert.Contains(t, result, testObject{value: 1})
-	assert.Contains(t, result, testObject{value: 2})
-	assert.Contains(t, result, testObject{value: 3})
-}
-
-func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) {
-	arr1 := []testObject{}
-	arr2 := []testObject{}
-	result := mergeUnique(arr1, arr2)
-	assert.Empty(t, result)
-}
-
-func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) {
-	arr1 := []testObject{{value: 1}, {value: 2}}
-	arr2 := []testObject{}
-	result := mergeUnique(arr1, arr2)
-	assert.Len(t, result, 2)
-	assert.Contains(t, result, testObject{value: 1})
-	assert.Contains(t, result, testObject{value: 2})
-}
diff --git a/shared/management/types/network_test.go b/shared/management/types/network_test.go
index d8a06dbbc..631f38836 100644
--- a/shared/management/types/network_test.go
+++ b/shared/management/types/network_test.go
@@ -1,264 +1,41 @@
 package types
 
 import (
-	"encoding/binary"
-	"net"
-	"net/netip"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 )
 
-func TestNewNetwork(t *testing.T) {
-	network := NewNetwork()
-
-	// generated net should be a subnet of a larger 100.64.0.0/10 net
-	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}}
-	assert.Equal(t, ipNet.Contains(network.Net.IP), true)
+type mergeTestObject struct {
+	value int
 }
 
-func TestAllocatePeerIP(t *testing.T) {
-	prefix := netip.MustParsePrefix("100.64.0.0/24")
-	var ips []netip.Addr
-	for i := 0; i < 252; i++ {
-		ip, err := AllocatePeerIP(prefix, ips)
-		if err != nil {
-			t.Fatal(err)
-		}
-		ips = append(ips, ip)
-	}
-
-	assert.Len(t, ips, 252)
-
-	uniq := make(map[string]struct{})
-	for _, ip := range ips {
-		if _, ok := uniq[ip.String()]; !ok {
-			uniq[ip.String()] = struct{}{}
-		} else {
-			t.Errorf("found duplicate IP %s", ip.String())
-		}
-	}
+func (t mergeTestObject) Equal(other mergeTestObject) bool {
+	return t.value == other.value
 }
 
-func TestAllocatePeerIPSmallSubnet(t *testing.T) {
-	// Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30)
-	prefix := netip.MustParsePrefix("10.0.0.0/27")
-	var ips []netip.Addr
-
-	// Allocate all available IPs in the /27 network
-	for i := 0; i < 30; i++ {
-		ip, err := AllocatePeerIP(prefix, ips)
-		if err != nil {
-			t.Fatal(err)
-		}
-
-		// Verify IP is within the correct range
-		if !prefix.Contains(ip) {
-			t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String())
-		}
-
-		ips = append(ips, ip)
-	}
-
-	assert.Len(t, ips, 30)
-
-	// Verify all IPs are unique
-	uniq := make(map[string]struct{})
-	for _, ip := range ips {
-		if _, ok := uniq[ip.String()]; !ok {
-			uniq[ip.String()] = struct{}{}
-		} else {
-			t.Errorf("found duplicate IP %s", ip.String())
-		}
-	}
-
-	// Try to allocate one more IP - should fail as network is full
-	_, err := AllocatePeerIP(prefix, ips)
-	if err == nil {
-		t.Error("expected error when network is full, but got none")
-	}
+func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) {
+	arr1 := []mergeTestObject{{value: 1}, {value: 2}}
+	arr2 := []mergeTestObject{{value: 2}, {value: 3}}
+	result := mergeUnique(arr1, arr2)
+	assert.Len(t, result, 3)
+	assert.Contains(t, result, mergeTestObject{value: 1})
+	assert.Contains(t, result, mergeTestObject{value: 2})
+	assert.Contains(t, result, mergeTestObject{value: 3})
 }
 
-func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
-	testCases := []struct {
-		name           string
-		cidr           string
-		expectedUsable int
-	}{
-		{"/30 network", "192.168.1.0/30", 2},   // 4 total - 2 reserved = 2 usable
-		{"/29 network", "192.168.1.0/29", 6},   // 8 total - 2 reserved = 6 usable
-		{"/28 network", "192.168.1.0/28", 14},  // 16 total - 2 reserved = 14 usable
-		{"/27 network", "192.168.1.0/27", 30},  // 32 total - 2 reserved = 30 usable
-		{"/26 network", "192.168.1.0/26", 62},  // 64 total - 2 reserved = 62 usable
-		{"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable
-		{"/16 network", "10.0.0.0/16", 65534},  // 65536 total - 2 reserved = 65534 usable
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			prefix, err := netip.ParsePrefix(tc.cidr)
-			require.NoError(t, err)
-			prefix = prefix.Masked()
-
-			var ips []netip.Addr
-
-			// For larger networks, test only a subset to avoid long test runs
-			testCount := tc.expectedUsable
-			if testCount > 1000 {
-				testCount = 1000
-			}
-
-			// Allocate IPs and verify they're within the correct range
-			for i := 0; i < testCount; i++ {
-				ip, err := AllocatePeerIP(prefix, ips)
-				require.NoError(t, err, "failed to allocate IP %d", i)
-
-				// Verify IP is within the correct range
-				assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String())
-
-				// Verify IP is not network or broadcast address
-				networkAddr := prefix.Masked().Addr()
-				hostBits := 32 - prefix.Bits()
-				b := networkAddr.As4()
-				baseIP := binary.BigEndian.Uint32(b[:])
-				broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1)
-
-				assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String())
-				assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String())
-
-				ips = append(ips, ip)
-			}
-
-			assert.Len(t, ips, testCount)
-
-			// Verify all IPs are unique
-			uniq := make(map[string]struct{})
-			for _, ip := range ips {
-				ipStr := ip.String()
-				assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr)
-				uniq[ipStr] = struct{}{}
-			}
-		})
-	}
+func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) {
+	arr1 := []mergeTestObject{}
+	arr2 := []mergeTestObject{}
+	result := mergeUnique(arr1, arr2)
+	assert.Empty(t, result)
 }
 
-func TestGenerateIPs(t *testing.T) {
-	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
-	ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}})
-	if ipsLen != 252 {
-		t.Errorf("expected 252 ips, got %d", len(ips))
-		return
-	}
-	if ips[len(ips)-1].String() != "100.64.0.253" {
-		t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String())
-	}
-}
-
-func TestNewNetworkHasIPv6(t *testing.T) {
-	network := NewNetwork()
-
-	assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated")
-	assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6")
-	assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)")
-
-	ones, bits := network.NetV6.Mask.Size()
-	assert.Equal(t, 64, ones, "v6 subnet should be /64")
-	assert.Equal(t, 128, bits)
-}
-
-func TestAllocateIPv6SubnetUniqueness(t *testing.T) {
-	seen := make(map[string]struct{})
-	for i := 0; i < 100; i++ {
-		network := NewNetwork()
-		key := network.NetV6.IP.String()
-		_, duplicate := seen[key]
-		assert.False(t, duplicate, "duplicate v6 subnet: %s", key)
-		seen[key] = struct{}{}
-	}
-}
-
-func TestAllocateRandomPeerIPv6(t *testing.T) {
-	prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64")
-
-	ip, err := AllocateRandomPeerIPv6(prefix)
-	require.NoError(t, err)
-
-	assert.True(t, ip.Is6(), "should be IPv6")
-	assert.True(t, prefix.Contains(ip), "should be within subnet")
-	// First 8 bytes (network prefix) should match
-	b := ip.As16()
-	prefixBytes := prefix.Addr().As16()
-	assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match")
-	// Interface ID should not be all zeros
-	allZero := true
-	for _, v := range b[8:] {
-		if v != 0 {
-			allZero = false
-			break
-		}
-	}
-	assert.False(t, allZero, "interface ID should not be all zeros")
-}
-
-func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) {
-	tests := []struct {
-		name   string
-		cidr   string
-		prefix int
-	}{
-		{"standard /64", "fd00:1234:5678:abcd::/64", 64},
-		{"small /112", "fd00:1234:5678:abcd::/112", 112},
-		{"large /48", "fd00:1234::/48", 48},
-		{"non-boundary /60", "fd00:1234:5670::/60", 60},
-		{"non-boundary /52", "fd00:1230::/52", 52},
-		{"minimum /120", "fd00:1234:5678:abcd::100/120", 120},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			prefix, err := netip.ParsePrefix(tt.cidr)
-			require.NoError(t, err)
-			prefix = prefix.Masked()
-
-			assert.Equal(t, tt.prefix, prefix.Bits())
-
-			for i := 0; i < 50; i++ {
-				ip, err := AllocateRandomPeerIPv6(prefix)
-				require.NoError(t, err)
-				assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
-			}
-		})
-	}
-}
-
-func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) {
-	// For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary
-	prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112")
-
-	prefixBytes := prefix.Addr().As16()
-	for i := 0; i < 20; i++ {
-		ip, err := AllocateRandomPeerIPv6(prefix)
-		require.NoError(t, err)
-		// First 14 bytes (112 bits = 14 bytes) must match the network
-		b := ip.As16()
-		assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112")
-	}
-}
-
-func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) {
-	// For a /60, the first 7.5 bytes are network, so byte 7 is partial
-	prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60")
-
-	prefixBytes := prefix.Addr().As16()
-	for i := 0; i < 50; i++ {
-		ip, err := AllocateRandomPeerIPv6(prefix)
-		require.NoError(t, err)
-		b := ip.As16()
-		assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
-		// First 7 bytes must match exactly
-		assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60")
-		// Byte 7: top 4 bits (0xc = 1100) must be preserved
-		assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60")
-	}
+func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) {
+	arr1 := []mergeTestObject{{value: 1}, {value: 2}}
+	arr2 := []mergeTestObject{}
+	result := mergeUnique(arr1, arr2)
+	assert.Len(t, result, 2)
+	assert.Contains(t, result, mergeTestObject{value: 1})
+	assert.Contains(t, result, mergeTestObject{value: 2})
 }
diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go
index c4c437e4b..d008ece83 100644
--- a/shared/management/types/networkmap_components.go
+++ b/shared/management/types/networkmap_components.go
@@ -14,32 +14,33 @@ import (
 	nbdns "github.com/netbirdio/netbird/dns"
 	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 type NetworkMapComponents struct {
 	PeerID string
 
-	Network          *Network
-	AccountSettings  *AccountSettingsInfo
-	DNSSettings      *DNSSettings
+	Network          *nmdata.Network
+	AccountSettings  *nmdata.AccountSettingsInfo
+	DNSSettings      *nmdata.DNSSettings
 	CustomZoneDomain string
 
-	Peers               map[string]*ComponentPeer
-	Groups              map[string]*ComponentGroup
-	Policies            []*Policy
-	Routes              []*route.Route
-	NameServerGroups    []*nbdns.NameServerGroup
-	AllDNSRecords       []nbdns.SimpleRecord
-	AccountZones        []nbdns.CustomZone
-	ResourcePoliciesMap map[string][]*Policy
-	RoutersMap          map[string]map[string]*ComponentRouter
-	NetworkResources    []*ComponentResource
+	Peers               map[string]*nmdata.Peer
+	Groups              map[string]*nmdata.Group
+	Policies            []*nmdata.Policy
+	Routes              []*nmdata.Route
+	NameServerGroups    []*nmdata.NameServerGroup
+	AllDNSRecords       []nmdata.SimpleRecord
+	AccountZones        []nmdata.CustomZone
+	ResourcePoliciesMap map[string][]*nmdata.Policy
+	RoutersMap          map[string]map[string]*nmdata.NetworkRouter
+	NetworkResources    []*nmdata.NetworkResource
 
 	GroupIDToUserIDs   map[string][]string
 	AllowedUserIDs     map[string]struct{}
 	PostureFailedPeers map[string]map[string]struct{}
 
-	RouterPeers map[string]*ComponentPeer
+	RouterPeers map[string]*nmdata.Peer
 
 	// NetworkXIDToPublicID maps Network.ID (xid) → PublicID.
 	// Consumed by the envelope encoder to
@@ -51,20 +52,21 @@ type NetworkMapComponents struct {
 	// Same role as NetworkXIDToPublicID, used for PostureFailedPeers keys and
 	// policy SourcePostureChecks references.
 	PostureCheckXIDToPublicID map[string]string
-	routesByPeerOnce          sync.Once
-	routesByPeerIdx           map[string][]routeIndexEntry
-
-	// true when returning an empty-like map (returned instead of nil)
-	empty bool
 
 	// ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS
 	// resolution regardless of the account-global setting, for reverse-proxy
 	// domain targets.
 	ForceRoutingPeerDNSResolution bool
+
+	routesByPeerOnce sync.Once
+	routesByPeerIdx  map[string][]routeIndexEntry
+
+	// true when returning an empty-like map (returned instead of nil)
+	empty bool
 }
 
 type routeIndexEntry struct {
-	route    *route.Route
+	route    *nmdata.Route
 	viaGroup bool
 }
 
@@ -80,15 +82,15 @@ func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents {
 	return nm
 }
 
-func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer {
+func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nmdata.Peer {
 	return c.Peers[peerID]
 }
 
-func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer {
+func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *nmdata.Peer {
 	return c.RouterPeers[peerID]
 }
 
-func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup {
+func (c *NetworkMapComponents) GetGroupInfo(groupID string) *nmdata.Group {
 	return c.Groups[groupID]
 }
 
@@ -143,8 +145,8 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
 	peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers)
 
 	includeIPv6 := false
-	if p := c.Peers[targetPeerID]; p != nil {
-		includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid()
+	if p := c.GetPeerInfo(targetPeerID); p != nil {
+		includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid()
 	}
 	routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6)
 	routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6)
@@ -175,11 +177,11 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
 		if c.CustomZoneDomain != "" && len(c.AllDNSRecords) > 0 {
 			customZones = append(customZones, nbdns.CustomZone{
 				Domain:  c.CustomZoneDomain,
-				Records: c.AllDNSRecords,
+				Records: toRealRecords(c.AllDNSRecords),
 			})
 		}
 
-		customZones = append(customZones, c.AccountZones...)
+		customZones = append(customZones, toRealZones(c.AccountZones)...)
 
 		dnsUpdate.CustomZones = customZones
 		dnsUpdate.NameServerGroups = c.getPeerNSGroupsFromGroups(targetPeerID, peerGroups)
@@ -187,7 +189,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
 
 	return &NetworkMap{
 		Peers:               peersToConnectIncludingRouters,
-		Network:             c.Network.Copy(),
+		Network:             c.Network,
 		Routes:              append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...),
 		DNSConfig:           dnsUpdate,
 		OfflinePeers:        expiredPeers,
@@ -204,7 +206,7 @@ func (c *NetworkMapComponents) IsEmpty() bool {
 	return c.empty
 }
 
-func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) {
+func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nmdata.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) {
 	targetPeer := c.GetPeerInfo(targetPeerID)
 	if targetPeer == nil {
 		return nil, nil, nil, false
@@ -215,25 +217,25 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
 	sshEnabled := false
 
 	for _, policy := range c.Policies {
-		if !policy.Enabled {
+		if policy == nil || !policy.Enabled {
 			continue
 		}
 
 		for _, rule := range policy.Rules {
-			if !rule.Enabled {
+			if rule == nil || !rule.Enabled {
 				continue
 			}
 
-			var sourcePeers, destinationPeers []*ComponentPeer
+			var sourcePeers, destinationPeers []*nmdata.Peer
 			var peerInSources, peerInDestinations bool
 
-			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+			if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" {
 				sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID)
 			} else {
 				sourcePeers, peerInSources = c.getAllPeersFromGroups(rule.Sources, targetPeerID, policy.SourcePostureChecks)
 			}
 
-			if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
+			if rule.DestinationResource.Type == string(ResourceTypePeer) && rule.DestinationResource.ID != "" {
 				destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID)
 			} else {
 				destinationPeers, peerInDestinations = c.getAllPeersFromGroups(rule.Destinations, targetPeerID, nil)
@@ -256,7 +258,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
 				generateResources(rule, sourcePeers, FirewallRuleDirectionIN)
 			}
 
-			if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
+			if peerInDestinations && rule.Protocol == string(PolicyRuleProtocolNetbirdSSH) {
 				sshEnabled = true
 				switch {
 				case len(rule.AuthorizedGroups) > 0:
@@ -287,7 +289,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
 				default:
 					authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
 				}
-			} else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
+			} else if peerInDestinations && nmdata.PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
 				sshEnabled = true
 				authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
 			}
@@ -307,19 +309,19 @@ func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} {
 	return make(map[string]struct{})
 }
 
-func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) {
+func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nmdata.Peer) (func(*nmdata.PolicyRule, []*nmdata.Peer, int), func() ([]*nmdata.Peer, []*FirewallRule)) {
 	rulesExists := make(map[string]struct{})
 	peersExists := make(map[string]struct{})
 	rules := make([]*FirewallRule, 0)
-	peers := make([]*ComponentPeer, 0)
+	peers := make([]*nmdata.Peer, 0)
 
-	return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) {
+	return func(rule *nmdata.PolicyRule, groupPeers []*nmdata.Peer, direction int) {
 			protocol := rule.Protocol
-			if protocol == PolicyRuleProtocolNetbirdSSH {
-				protocol = PolicyRuleProtocolTCP
+			if protocol == string(PolicyRuleProtocolNetbirdSSH) {
+				protocol = string(PolicyRuleProtocolTCP)
 			}
 
-			protocolStr := string(protocol)
+			protocolStr := protocol
 			actionStr := string(rule.Action)
 			dirStr := strconv.Itoa(direction)
 			portsJoined := strings.Join(rule.Ports, ",")
@@ -365,15 +367,15 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer)
 					PortsJoined: portsJoined,
 				})
 			}
-		}, func() ([]*ComponentPeer, []*FirewallRule) {
+		}, func() ([]*nmdata.Peer, []*FirewallRule) {
 			return peers, rules
 		}
 }
 
-func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) {
+func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*nmdata.Peer, bool) {
 	peerInGroups := false
 	uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups)
-	filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs))
+	filteredPeers := make([]*nmdata.Peer, 0, len(uniquePeerIDs))
 
 	for _, p := range uniquePeerIDs {
 		peerInfo := c.GetPeerInfo(p)
@@ -425,22 +427,22 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) []
 	return ids
 }
 
-func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) {
+func (c *NetworkMapComponents) getPeerFromResource(resource nmdata.Resource, peerID string) ([]*nmdata.Peer, bool) {
 	if resource.ID == peerID {
-		return []*ComponentPeer{}, true
+		return []*nmdata.Peer{}, true
 	}
 
 	peerInfo := c.GetPeerInfo(resource.ID)
 	if peerInfo == nil {
-		return []*ComponentPeer{}, false
+		return []*nmdata.Peer{}, false
 	}
 
-	return []*ComponentPeer{peerInfo}, false
+	return []*nmdata.Peer{peerInfo}, false
 }
 
-func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) {
-	peersToConnect := make([]*ComponentPeer, 0, len(aclPeers))
-	var expiredPeers []*ComponentPeer
+func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nmdata.Peer) ([]*nmdata.Peer, []*nmdata.Peer) {
+	peersToConnect := make([]*nmdata.Peer, 0, len(aclPeers))
+	var expiredPeers []*nmdata.Peer
 
 	for _, p := range aclPeers {
 		expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration)
@@ -480,7 +482,7 @@ func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupLis
 		for _, gID := range nsGroup.Groups {
 			if _, found := groupList[gID]; found {
 				if !c.peerIsNameserver(peerIPStr, nsGroup) {
-					peerNSGroups = append(peerNSGroups, nsGroup.Copy())
+					peerNSGroups = append(peerNSGroups, toRealNSGroup(nsGroup))
 				}
 				break
 			}
@@ -490,7 +492,7 @@ func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupLis
 	return peerNSGroups
 }
 
-func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns.NameServerGroup) bool {
+func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nmdata.NameServerGroup) bool {
 	for _, ns := range nsGroup.NameServers {
 		if peerIPStr == ns.IP.String() {
 			return true
@@ -502,8 +504,8 @@ func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns
 // filterAndExpandRoutes drops v6 routes for non-capable peers and duplicates
 // the default v4 route (0.0.0.0/0) as ::/0 for v6-capable peers.
 // TODO: the "-v6" suffix on IDs could collide with user-supplied route IDs.
-func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Route {
-	filtered := make([]*route.Route, 0, len(routes))
+func filterAndExpandRoutes(routes []*nmdata.Route, includeIPv6 bool) []*nmdata.Route {
+	filtered := make([]*nmdata.Route, 0, len(routes))
 	for _, r := range routes {
 		if !includeIPv6 && r.Network.Addr().Is6() {
 			continue
@@ -515,14 +517,14 @@ func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Rou
 			v6.ID = r.ID + "-v6-default"
 			v6.NetID = r.NetID + "-v6"
 			v6.Network = netip.MustParsePrefix("::/0")
-			v6.NetworkType = route.IPv6Network
+			v6.NetworkType = nmdata.NetworkTypeIPv6
 			filtered = append(filtered, v6)
 		}
 	}
 	return filtered
 }
 
-func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route {
+func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nmdata.Peer, peerGroups LookupMap) []*nmdata.Route {
 	routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID)
 	peerRoutesMembership := make(LookupMap)
 	for _, r := range append(routes, peerDisabledRoutes...) {
@@ -539,7 +541,7 @@ func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*Compon
 	return routes
 }
 
-func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*route.Route, disabledRoutes []*route.Route) {
+func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*nmdata.Route, disabledRoutes []*nmdata.Route) {
 	peerInfo := c.GetPeerInfo(peerID)
 	if peerInfo == nil {
 		peerInfo = c.GetRouterPeerInfo(peerID)
@@ -548,9 +550,9 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
 		return enabledRoutes, disabledRoutes
 	}
 
-	seenRoute := make(map[route.ID]struct{})
+	seenRoute := make(map[string]struct{})
 
-	takeRoute := func(r *route.Route) {
+	takeRoute := func(r *nmdata.Route) {
 		if _, ok := seenRoute[r.ID]; ok {
 			return
 		}
@@ -569,7 +571,7 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
 		if entry.viaGroup {
 			newPeerRoute := entry.route.Copy()
 			newPeerRoute.PeerGroups = nil
-			newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID)
+			newPeerRoute.ID = entry.route.ID + ":" + peerID
 			takeRoute(newPeerRoute)
 			continue
 		}
@@ -602,8 +604,8 @@ func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry {
 	return c.routesByPeerIdx
 }
 
-func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
-	var filteredRoutes []*route.Route
+func (c *NetworkMapComponents) filterRoutesByGroups(routes []*nmdata.Route, groupListMap LookupMap) []*nmdata.Route {
+	var filteredRoutes []*nmdata.Route
 	for _, r := range routes {
 		for _, groupID := range r.Groups {
 			_, found := groupListMap[groupID]
@@ -616,8 +618,8 @@ func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, group
 	return filteredRoutes
 }
 
-func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*route.Route, peerMemberships LookupMap) []*route.Route {
-	var filteredRoutes []*route.Route
+func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*nmdata.Route, peerMemberships LookupMap) []*nmdata.Route {
+	var filteredRoutes []*nmdata.Route
 	for _, r := range routes {
 		_, found := peerMemberships[string(r.GetHAUniqueID())]
 		if !found {
@@ -650,7 +652,7 @@ func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, p
 	return routesFirewallRules
 }
 
-func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool) []*RouteFirewallRule {
+func (c *NetworkMapComponents) getDefaultPermit(r *nmdata.Route, includeIPv6 bool) []*RouteFirewallRule {
 	if r.Network.Addr().Is6() && !includeIPv6 {
 		return nil
 	}
@@ -667,7 +669,7 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
 		Protocol:     string(PolicyRuleProtocolALL),
 		Domains:      r.Domains,
 		IsDynamic:    r.IsDynamic(),
-		RouteID:      r.ID,
+		RouteID:      route.ID(r.ID),
 	}
 
 	rules := []*RouteFirewallRule{&rule}
@@ -678,7 +680,7 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
 		ruleV6.SourceRanges = []string{"::/0"}
 		if isDefaultV4 {
 			ruleV6.Destination = "::/0"
-			ruleV6.RouteID = r.ID + "-v6-default"
+			ruleV6.RouteID = route.ID(r.ID + "-v6-default")
 		}
 		rules = append(rules, &ruleV6)
 	}
@@ -686,7 +688,7 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
 	return rules
 }
 
-func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[string]struct{} {
+func (c *NetworkMapComponents) getDistributionGroupsPeers(r *nmdata.Route) map[string]struct{} {
 	distPeers := make(map[string]struct{})
 	for _, id := range r.Groups {
 		group := c.GetGroupInfo(id)
@@ -701,11 +703,17 @@ func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[st
 	return distPeers
 }
 
-func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*Policy {
-	routePolicies := make([]*Policy, 0)
+func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*nmdata.Policy {
+	routePolicies := make([]*nmdata.Policy, 0)
 	for _, groupID := range accessControlGroups {
 		for _, policy := range c.Policies {
+			if policy == nil {
+				continue
+			}
 			for _, rule := range policy.Rules {
+				if rule == nil {
+					continue
+				}
 				if slices.Contains(rule.Destinations, groupID) {
 					routePolicies = append(routePolicies, policy)
 				}
@@ -716,15 +724,15 @@ func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups
 	return routePolicies
 }
 
-func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
+func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*nmdata.Policy, route *nmdata.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
 	var fwRules []*RouteFirewallRule
 	for _, policy := range policies {
-		if !policy.Enabled {
+		if policy == nil || !policy.Enabled {
 			continue
 		}
 
 		for _, rule := range policy.Rules {
-			if !rule.Enabled {
+			if rule == nil || !rule.Enabled {
 				continue
 			}
 
@@ -736,7 +744,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID
 	return fwRules
 }
 
-func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer {
+func (c *NetworkMapComponents) getRulePeers(rule *nmdata.PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*nmdata.Peer {
 	distPeersWithPolicy := make(map[string]struct{})
 	for _, id := range rule.Sources {
 		group := c.GetGroupInfo(id)
@@ -755,7 +763,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st
 			}
 		}
 	}
-	if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+	if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" {
 		_, distPeer := distributionPeers[rule.SourceResource.ID]
 		_, valid := c.Peers[rule.SourceResource.ID]
 		if distPeer && valid && c.ValidatePostureChecksOnPeer(rule.SourceResource.ID, postureChecks) {
@@ -763,7 +771,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st
 		}
 	}
 
-	distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy))
+	distributionGroupPeers := make([]*nmdata.Peer, 0, len(distPeersWithPolicy))
 	for pID := range distPeersWithPolicy {
 		peerInfo := c.GetPeerInfo(pID)
 		if peerInfo == nil {
@@ -774,9 +782,9 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st
 	return distributionGroupPeers
 }
 
-func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*route.Route, map[string]struct{}) {
+func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*nmdata.Route, map[string]struct{}) {
 	var isRoutingPeer bool
-	var routes []*route.Route
+	var routes []*nmdata.Route
 	allSourcePeers := make(map[string]struct{})
 
 	for _, resource := range c.NetworkResources {
@@ -803,14 +811,17 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (b
 
 func (c *NetworkMapComponents) processResourcePolicies(
 	peerID string,
-	resource *ComponentResource,
-	networkRoutingPeers map[string]*ComponentRouter,
+	resource *nmdata.NetworkResource,
+	networkRoutingPeers map[string]*nmdata.NetworkRouter,
 	addSourcePeers bool,
 	allSourcePeers map[string]struct{},
-) []*route.Route {
-	var routes []*route.Route
+) []*nmdata.Route {
+	var routes []*nmdata.Route
 
 	for _, policy := range c.ResourcePoliciesMap[resource.ID] {
+		if policy == nil || !policy.Enabled || len(policy.Rules) == 0 || policy.Rules[0] == nil {
+			continue
+		}
 		peers := c.getResourcePolicyPeers(policy)
 		if addSourcePeers {
 			for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) {
@@ -830,17 +841,17 @@ func (c *NetworkMapComponents) processResourcePolicies(
 	return routes
 }
 
-func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string {
-	if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
+func (c *NetworkMapComponents) getResourcePolicyPeers(policy *nmdata.Policy) []string {
+	if policy.Rules[0].SourceResource.Type == string(ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" {
 		return []string{policy.Rules[0].SourceResource.ID}
 	}
 	return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
 }
 
-func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route {
+func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *nmdata.NetworkResource, peerID string, router *nmdata.NetworkRouter) []*nmdata.Route {
 	resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID]
 
-	var routes []*route.Route
+	var routes []*nmdata.Route
 	if len(resourceAppliedPolicies) > 0 {
 		peerInfo := c.GetPeerInfo(peerID)
 		if peerInfo != nil {
@@ -851,9 +862,9 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentReso
 	return routes
 }
 
-func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route {
-	r := &route.Route{
-		ID:          route.ID(resource.ID + ":" + peer.ID),
+func (c *NetworkMapComponents) networkResourceToRoute(resource *nmdata.NetworkResource, peer *nmdata.Peer, router *nmdata.NetworkRouter) *nmdata.Route {
+	r := &nmdata.Route{
+		ID:          resource.ID + ":" + peer.ID,
 		AccountID:   resource.AccountID,
 		Peer:        peer.Key,
 		PeerID:      peer.ID,
@@ -861,24 +872,24 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResourc
 		Masquerade:  router.Masquerade,
 		Enabled:     resource.Enabled,
 		KeepRoute:   true,
-		NetID:       route.NetID(resource.Name),
+		NetID:       resource.Name,
 		Description: resource.Description,
 	}
 
-	if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet {
+	if resource.Type == string(ResourceTypeHost) || resource.Type == string(ResourceTypeSubnet) {
 		r.Network = resource.Prefix
 
-		r.NetworkType = route.IPv4Network
+		r.NetworkType = nmdata.NetworkTypeIPv4
 		if resource.Prefix.Addr().Is6() {
-			r.NetworkType = route.IPv6Network
+			r.NetworkType = nmdata.NetworkTypeIPv6
 		}
 	}
 
-	if resource.Type == ComponentResourceDomain {
+	if resource.Type == string(ResourceTypeDomain) {
 		domainList, err := domain.FromStringList([]string{resource.Domain})
 		if err == nil {
 			r.Domains = domainList
-			r.NetworkType = route.DomainNetwork
+			r.NetworkType = nmdata.NetworkTypeDomain
 			r.Network = netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32)
 		}
 	}
@@ -896,7 +907,7 @@ func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, posture
 	return dest
 }
 
-func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route, includeIPv6 bool) []*RouteFirewallRule {
+func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*nmdata.Route, includeIPv6 bool) []*RouteFirewallRule {
 	routesFirewallRules := make([]*RouteFirewallRule, 0)
 
 	peerInfo := c.GetPeerInfo(peerID)
@@ -924,11 +935,17 @@ func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.C
 	return routesFirewallRules
 }
 
-func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[string]struct{} {
+func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*nmdata.Policy) map[string]struct{} {
 	sourcePeers := make(map[string]struct{})
 
 	for _, policy := range policies {
+		if policy == nil {
+			continue
+		}
 		for _, rule := range policy.Rules {
+			if rule == nil {
+				continue
+			}
 			for _, sourceGroup := range rule.Sources {
 				group := c.GetGroupInfo(sourceGroup)
 				if group == nil {
@@ -940,7 +957,7 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st
 				}
 			}
 
-			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+			if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" {
 				sourcePeers[rule.SourceResource.ID] = struct{}{}
 			}
 		}
@@ -950,13 +967,13 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st
 }
 
 func (c *NetworkMapComponents) addNetworksRoutingPeers(
-	networkResourcesRoutes []*route.Route,
+	networkResourcesRoutes []*nmdata.Route,
 	peerID string,
-	peersToConnect []*ComponentPeer,
-	expiredPeers []*ComponentPeer,
+	peersToConnect []*nmdata.Peer,
+	expiredPeers []*nmdata.Peer,
 	isRouter bool,
 	sourcePeers map[string]struct{},
-) []*ComponentPeer {
+) []*nmdata.Peer {
 
 	networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes))
 	for _, r := range networkResourcesRoutes {
@@ -1006,8 +1023,8 @@ type FirewallRuleContext struct {
 	PortsJoined string
 }
 
-func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
-	if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() {
+func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nmdata.Peer, rule *nmdata.PolicyRule, rc FirewallRuleContext) []*FirewallRule {
+	if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() {
 		return rules
 	}
 
diff --git a/shared/management/types/networkmap_components_compact.go b/shared/management/types/networkmap_components_compact.go
index a1f53690d..b45bc3e40 100644
--- a/shared/management/types/networkmap_components_compact.go
+++ b/shared/management/types/networkmap_components_compact.go
@@ -1,8 +1,7 @@
 package types
 
 import (
-	nbdns "github.com/netbirdio/netbird/dns"
-	"github.com/netbirdio/netbird/route"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 type GroupCompact struct {
@@ -13,26 +12,26 @@ type GroupCompact struct {
 type NetworkMapComponentsCompact struct {
 	PeerID string
 
-	Network          *Network
-	AccountSettings  *AccountSettingsInfo
-	DNSSettings      *DNSSettings
+	Network          *nmdata.Network
+	AccountSettings  *nmdata.AccountSettingsInfo
+	DNSSettings      *nmdata.DNSSettings
 	CustomZoneDomain string
 
-	AllPeers          []*ComponentPeer
+	AllPeers          []*nmdata.Peer
 	PeerIndexes       []int
 	RouterPeerIndexes []int
 
 	Groups              map[string]*GroupCompact
-	AllPolicies         []*Policy
+	AllPolicies         []*nmdata.Policy
 	PolicyIndexes       []int
 	ResourcePoliciesMap map[string][]int
-	Routes              []*route.Route
-	NameServerGroups    []*nbdns.NameServerGroup
-	AllDNSRecords       []nbdns.SimpleRecord
-	AccountZones        []nbdns.CustomZone
+	Routes              []*nmdata.Route
+	NameServerGroups    []*nmdata.NameServerGroup
+	AllDNSRecords       []nmdata.SimpleRecord
+	AccountZones        []nmdata.CustomZone
 
-	RoutersMap       map[string]map[string]*ComponentRouter
-	NetworkResources []*ComponentResource
+	RoutersMap       map[string]map[string]*nmdata.NetworkRouter
+	NetworkResources []*nmdata.NetworkResource
 
 	GroupIDToUserIDs   map[string][]string
 	AllowedUserIDs     map[string]struct{}
@@ -41,7 +40,7 @@ type NetworkMapComponentsCompact struct {
 
 func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact {
 	peerToIndex := make(map[string]int)
-	var allPeers []*ComponentPeer
+	var allPeers []*nmdata.Peer
 
 	for id, peer := range c.Peers {
 		if _, exists := peerToIndex[id]; !exists {
@@ -81,8 +80,8 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact {
 		}
 	}
 
-	policyToIndex := make(map[*Policy]int)
-	var allPolicies []*Policy
+	policyToIndex := make(map[*nmdata.Policy]int)
+	var allPolicies []*nmdata.Policy
 
 	for _, policy := range c.Policies {
 		if _, exists := policyToIndex[policy]; !exists {
@@ -147,7 +146,7 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact {
 }
 
 func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents {
-	peers := make(map[string]*ComponentPeer, len(c.PeerIndexes))
+	peers := make(map[string]*nmdata.Peer, len(c.PeerIndexes))
 	for _, idx := range c.PeerIndexes {
 		if idx >= 0 && idx < len(c.AllPeers) {
 			peer := c.AllPeers[idx]
@@ -155,7 +154,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents {
 		}
 	}
 
-	routerPeers := make(map[string]*ComponentPeer, len(c.RouterPeerIndexes))
+	routerPeers := make(map[string]*nmdata.Peer, len(c.RouterPeerIndexes))
 	for _, idx := range c.RouterPeerIndexes {
 		if idx >= 0 && idx < len(c.AllPeers) {
 			peer := c.AllPeers[idx]
@@ -163,7 +162,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents {
 		}
 	}
 
-	groups := make(map[string]*ComponentGroup, len(c.Groups))
+	groups := make(map[string]*nmdata.Group, len(c.Groups))
 	for id, gc := range c.Groups {
 		peerIDs := make([]string, 0, len(gc.PeerIndexes))
 		for _, idx := range gc.PeerIndexes {
@@ -171,25 +170,24 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents {
 				peerIDs = append(peerIDs, c.AllPeers[idx].ID)
 			}
 		}
-		groups[id] = &ComponentGroup{
-			ID:    id,
+		groups[id] = &nmdata.Group{
 			Name:  gc.Name,
 			Peers: peerIDs,
 		}
 	}
 
-	policies := make([]*Policy, len(c.PolicyIndexes))
+	policies := make([]*nmdata.Policy, len(c.PolicyIndexes))
 	for i, idx := range c.PolicyIndexes {
 		if idx >= 0 && idx < len(c.AllPolicies) {
 			policies[i] = c.AllPolicies[idx]
 		}
 	}
 
-	var resourcePoliciesMap map[string][]*Policy
+	var resourcePoliciesMap map[string][]*nmdata.Policy
 	if len(c.ResourcePoliciesMap) > 0 {
-		resourcePoliciesMap = make(map[string][]*Policy, len(c.ResourcePoliciesMap))
+		resourcePoliciesMap = make(map[string][]*nmdata.Policy, len(c.ResourcePoliciesMap))
 		for resID, indexes := range c.ResourcePoliciesMap {
-			pols := make([]*Policy, 0, len(indexes))
+			pols := make([]*nmdata.Policy, 0, len(indexes))
 			for _, idx := range indexes {
 				if idx >= 0 && idx < len(c.AllPolicies) {
 					pols = append(pols, c.AllPolicies[idx])
diff --git a/shared/management/types/nmdata_convert.go b/shared/management/types/nmdata_convert.go
new file mode 100644
index 000000000..2a7998773
--- /dev/null
+++ b/shared/management/types/nmdata_convert.go
@@ -0,0 +1,70 @@
+package types
+
+import (
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// This file holds the twin→real converters that survive the twin-NetworkMap
+// refactor: only the DNS materialization. NetworkMap.DNSConfig stays a real
+// nbdns.Config (the client DNS type), so Calculate converts the twin DNS
+// components to nbdns at the output boundary. Peers/Routes/Network flow as
+// twins all the way through and need no conversion.
+
+func toRealNSGroup(n *nmdata.NameServerGroup) *nbdns.NameServerGroup {
+	if n == nil {
+		return nil
+	}
+	nameServers := make([]nbdns.NameServer, 0, len(n.NameServers))
+	for _, ns := range n.NameServers {
+		nameServers = append(nameServers, nbdns.NameServer{
+			IP:     ns.IP,
+			NSType: nbdns.NameServerType(ns.NSType),
+			Port:   ns.Port,
+		})
+	}
+	return &nbdns.NameServerGroup{
+		ID:                   n.ID,
+		Name:                 n.Name,
+		Description:          n.Description,
+		NameServers:          nameServers,
+		Groups:               n.Groups,
+		Primary:              n.Primary,
+		Domains:              n.Domains,
+		Enabled:              n.Enabled,
+		SearchDomainsEnabled: n.SearchDomainsEnabled,
+	}
+}
+
+func toRealRecords(recs []nmdata.SimpleRecord) []nbdns.SimpleRecord {
+	if recs == nil {
+		return nil
+	}
+	out := make([]nbdns.SimpleRecord, len(recs))
+	for i, r := range recs {
+		out[i] = nbdns.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		}
+	}
+	return out
+}
+
+func toRealZones(zones []nmdata.CustomZone) []nbdns.CustomZone {
+	if zones == nil {
+		return nil
+	}
+	out := make([]nbdns.CustomZone, len(zones))
+	for i, z := range zones {
+		out[i] = nbdns.CustomZone{
+			Domain:               z.Domain,
+			Records:              toRealRecords(z.Records),
+			SearchDomainDisabled: z.SearchDomainDisabled,
+			NonAuthoritative:     z.NonAuthoritative,
+		}
+	}
+	return out
+}
diff --git a/shared/management/types/policyrule.go b/shared/management/types/policyrule.go
index 52c494a6a..c951b1487 100644
--- a/shared/management/types/policyrule.go
+++ b/shared/management/types/policyrule.go
@@ -1,22 +1,39 @@
 package types
 
 import (
-	"slices"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
 
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
-// PolicyUpdateOperationType operation type
-type PolicyUpdateOperationType int
-
 // PolicyTrafficActionType action type for the firewall
 type PolicyTrafficActionType string
 
 // PolicyRuleProtocolType type of traffic
 type PolicyRuleProtocolType string
 
-// PolicyRuleDirection direction of traffic
-type PolicyRuleDirection string
+const (
+	// PolicyTrafficActionAccept indicates that the traffic is accepted
+	PolicyTrafficActionAccept = PolicyTrafficActionType("accept")
+	// PolicyTrafficActionDrop indicates that the traffic is dropped
+	PolicyTrafficActionDrop = PolicyTrafficActionType("drop")
+)
+
+const (
+	// PolicyRuleProtocolALL type of traffic
+	PolicyRuleProtocolALL = PolicyRuleProtocolType("all")
+	// PolicyRuleProtocolTCP type of traffic
+	PolicyRuleProtocolTCP = PolicyRuleProtocolType("tcp")
+	// PolicyRuleProtocolUDP type of traffic
+	PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp")
+	// PolicyRuleProtocolICMP type of traffic
+	PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp")
+	// PolicyRuleProtocolNetbirdSSH type of traffic
+	PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh")
+)
 
 // RulePortRange represents a range of ports for a firewall rule.
 type RulePortRange struct {
@@ -39,187 +56,84 @@ func (r *RulePortRange) Equal(other *RulePortRange) bool {
 	return r.Start == other.Start && r.End == other.End
 }
 
-// PolicyRule is the metadata of the policy
-type PolicyRule struct {
-	// ID of the policy rule
-	ID string `gorm:"primaryKey"`
+func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) {
+	rule = strings.TrimSpace(strings.ToLower(rule))
+	if rule == "all" {
+		return PolicyRuleProtocolALL, RulePortRange{}, nil
+	}
+	if rule == "icmp" {
+		return PolicyRuleProtocolICMP, RulePortRange{}, nil
+	}
 
-	// PolicyID is a reference to Policy that this object belongs
-	PolicyID string `json:"-" gorm:"index"`
+	split := strings.Split(rule, "/")
+	if len(split) != 2 {
+		return "", RulePortRange{}, errors.New("invalid rule format: expected protocol/port or protocol/port-range")
+	}
 
-	// Name of the rule visible in the UI
-	Name string
+	protoStr := strings.TrimSpace(split[0])
+	portStr := strings.TrimSpace(split[1])
 
-	// Description of the rule visible in the UI
-	Description string
+	var protocol PolicyRuleProtocolType
+	switch protoStr {
+	case "tcp":
+		protocol = PolicyRuleProtocolTCP
+	case "udp":
+		protocol = PolicyRuleProtocolUDP
+	case "icmp":
+		return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'")
+	case "netbird-ssh":
+		return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil
+	default:
+		return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr)
+	}
 
-	// Enabled status of rule in the system
-	Enabled bool
+	portRange, err := parsePortRange(portStr)
+	if err != nil {
+		return "", RulePortRange{}, err
+	}
 
-	// Action policy accept or drops packets
-	Action PolicyTrafficActionType
-
-	// Destinations policy destination groups
-	Destinations []string `gorm:"serializer:json"`
-
-	// DestinationResource policy destination resource that the rule is applied to
-	DestinationResource Resource `gorm:"serializer:json"`
-
-	// Sources policy source groups
-	Sources []string `gorm:"serializer:json"`
-
-	// SourceResource policy source resource that the rule is applied to
-	SourceResource Resource `gorm:"serializer:json"`
-
-	// Bidirectional define if the rule is applicable in both directions, sources, and destinations
-	Bidirectional bool
-
-	// Protocol type of the traffic
-	Protocol PolicyRuleProtocolType
-
-	// Ports or it ranges list
-	Ports []string `gorm:"serializer:json"`
-
-	// PortRanges a list of port ranges.
-	PortRanges []RulePortRange `gorm:"serializer:json"`
-
-	// AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh
-	AuthorizedGroups map[string][]string `gorm:"serializer:json"`
-
-	// AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh
-	AuthorizedUser string
+	return protocol, portRange, nil
 }
 
-// Copy returns a copy of a policy rule
-func (pm *PolicyRule) Copy() *PolicyRule {
-	rule := &PolicyRule{
-		ID:                  pm.ID,
-		PolicyID:            pm.PolicyID,
-		Name:                pm.Name,
-		Description:         pm.Description,
-		Enabled:             pm.Enabled,
-		Action:              pm.Action,
-		Destinations:        make([]string, len(pm.Destinations)),
-		DestinationResource: pm.DestinationResource,
-		Sources:             make([]string, len(pm.Sources)),
-		SourceResource:      pm.SourceResource,
-		Bidirectional:       pm.Bidirectional,
-		Protocol:            pm.Protocol,
-		Ports:               make([]string, len(pm.Ports)),
-		PortRanges:          make([]RulePortRange, len(pm.PortRanges)),
-		AuthorizedGroups:    make(map[string][]string, len(pm.AuthorizedGroups)),
-		AuthorizedUser:      pm.AuthorizedUser,
-	}
-	copy(rule.Destinations, pm.Destinations)
-	copy(rule.Sources, pm.Sources)
-	copy(rule.Ports, pm.Ports)
-	copy(rule.PortRanges, pm.PortRanges)
-	for k, v := range pm.AuthorizedGroups {
-		rule.AuthorizedGroups[k] = make([]string, len(v))
-		copy(rule.AuthorizedGroups[k], v)
-	}
-	return rule
-}
-
-func (pm *PolicyRule) Equal(other *PolicyRule) bool {
-	if pm == nil || other == nil {
-		return pm == other
-	}
-
-	if pm.ID != other.ID ||
-		pm.PolicyID != other.PolicyID ||
-		pm.Name != other.Name ||
-		pm.Description != other.Description ||
-		pm.Enabled != other.Enabled ||
-		pm.Action != other.Action ||
-		pm.Bidirectional != other.Bidirectional ||
-		pm.Protocol != other.Protocol ||
-		pm.SourceResource != other.SourceResource ||
-		pm.DestinationResource != other.DestinationResource ||
-		pm.AuthorizedUser != other.AuthorizedUser {
-		return false
-	}
-
-	if !stringSlicesEqualUnordered(pm.Sources, other.Sources) {
-		return false
-	}
-	if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) {
-		return false
-	}
-	if !stringSlicesEqualUnordered(pm.Ports, other.Ports) {
-		return false
-	}
-	if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) {
-		return false
-	}
-	if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) {
-		return false
-	}
-
-	return true
-}
-
-func stringSlicesEqualUnordered(a, b []string) bool {
-	if len(a) != len(b) {
-		return false
-	}
-	if len(a) == 0 {
-		return true
-	}
-	sorted1 := make([]string, len(a))
-	sorted2 := make([]string, len(b))
-	copy(sorted1, a)
-	copy(sorted2, b)
-	slices.Sort(sorted1)
-	slices.Sort(sorted2)
-	return slices.Equal(sorted1, sorted2)
-}
-
-func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool {
-	if len(a) != len(b) {
-		return false
-	}
-	if len(a) == 0 {
-		return true
-	}
-	cmp := func(x, y RulePortRange) int {
-		if x.Start != y.Start {
-			if x.Start < y.Start {
-				return -1
-			}
-			return 1
+func parsePortRange(portStr string) (RulePortRange, error) {
+	if strings.Contains(portStr, "-") {
+		rangeParts := strings.Split(portStr, "-")
+		if len(rangeParts) != 2 {
+			return RulePortRange{}, fmt.Errorf("invalid port range %q", portStr)
 		}
-		if x.End != y.End {
-			if x.End < y.End {
-				return -1
-			}
-			return 1
+		start, err := parsePort(strings.TrimSpace(rangeParts[0]))
+		if err != nil {
+			return RulePortRange{}, err
 		}
-		return 0
+		end, err := parsePort(strings.TrimSpace(rangeParts[1]))
+		if err != nil {
+			return RulePortRange{}, err
+		}
+		if start > end {
+			return RulePortRange{}, fmt.Errorf("invalid port range: start %d > end %d", start, end)
+		}
+		return RulePortRange{Start: uint16(start), End: uint16(end)}, nil
 	}
-	sorted1 := make([]RulePortRange, len(a))
-	sorted2 := make([]RulePortRange, len(b))
-	copy(sorted1, a)
-	copy(sorted2, b)
-	slices.SortFunc(sorted1, cmp)
-	slices.SortFunc(sorted2, cmp)
-	return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool {
-		return x.Start == y.Start && x.End == y.End
-	})
+
+	p, err := parsePort(portStr)
+	if err != nil {
+		return RulePortRange{}, err
+	}
+
+	return RulePortRange{Start: uint16(p), End: uint16(p)}, nil
 }
 
-func authorizedGroupsEqual(a, b map[string][]string) bool {
-	if len(a) != len(b) {
-		return false
+func parsePort(portStr string) (int, error) {
+
+	if portStr == "" {
+		return 0, errors.New("empty port")
 	}
-	for k, va := range a {
-		vb, ok := b[k]
-		if !ok {
-			return false
-		}
-		if !stringSlicesEqualUnordered(va, vb) {
-			return false
-		}
+	p, err := strconv.Atoi(portStr)
+	if err != nil {
+		return 0, fmt.Errorf("invalid port %q: %w", portStr, err)
 	}
-	return true
+	if p < 1 || p > 65535 {
+		return 0, fmt.Errorf("port out of range (1–65535): %d", p)
+	}
+	return p, nil
 }
diff --git a/shared/management/types/resource.go b/shared/management/types/resource.go
index 8347d8c03..87f27db49 100644
--- a/shared/management/types/resource.go
+++ b/shared/management/types/resource.go
@@ -1,9 +1,5 @@
 package types
 
-import (
-	"github.com/netbirdio/netbird/shared/management/http/api"
-)
-
 type ResourceType string
 
 const (
@@ -13,27 +9,11 @@ const (
 	ResourceTypeSubnet ResourceType = "subnet"
 )
 
-type Resource struct {
-	ID   string
-	Type ResourceType
-}
-
-func (r *Resource) ToAPIResponse() *api.Resource {
-	if r.ID == "" && r.Type == "" {
-		return nil
-	}
-
-	return &api.Resource{
-		Id:   r.ID,
-		Type: api.ResourceType(r.Type),
+func (t ResourceType) Valid() bool {
+	switch t {
+	case ResourceTypePeer, ResourceTypeDomain, ResourceTypeHost, ResourceTypeSubnet:
+		return true
+	default:
+		return false
 	}
 }
-
-func (r *Resource) FromAPIRequest(req *api.Resource) {
-	if req == nil {
-		return
-	}
-
-	r.ID = req.Id
-	r.Type = ResourceType(req.Type)
-}
diff --git a/version/compare.go b/version/compare.go
new file mode 100644
index 000000000..e7868f35a
--- /dev/null
+++ b/version/compare.go
@@ -0,0 +1,31 @@
+package version
+
+import (
+	"strings"
+
+	v "github.com/hashicorp/go-version"
+)
+
+// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.)
+func sanitizeVersion(version string) string {
+	parts := strings.Split(version, "-")
+	return parts[0]
+}
+
+// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version
+func MeetsMinVersion(minVer, peerVer string) (bool, error) {
+	peerVer = sanitizeVersion(peerVer)
+	minVer = sanitizeVersion(minVer)
+
+	peerNBVer, err := v.NewVersion(peerVer)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := v.NewConstraint(">= " + minVer)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVer), nil
+}
diff --git a/version/compare_test.go b/version/compare_test.go
new file mode 100644
index 000000000..9f3c7f323
--- /dev/null
+++ b/version/compare_test.go
@@ -0,0 +1,72 @@
+package version
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMeetsMinVersion(t *testing.T) {
+	tests := []struct {
+		name    string
+		minVer  string
+		peerVer string
+		want    bool
+		wantErr bool
+	}{
+		{
+			name:    "Peer version greater than min version",
+			minVer:  "0.26.0",
+			peerVer: "0.60.1",
+			want:    true,
+			wantErr: false,
+		},
+		{
+			name:    "Peer version equals min version",
+			minVer:  "1.0.0",
+			peerVer: "1.0.0",
+			want:    true,
+			wantErr: false,
+		},
+		{
+			name:    "Peer version less than min version",
+			minVer:  "1.0.0",
+			peerVer: "0.9.9",
+			want:    false,
+			wantErr: false,
+		},
+		{
+			name:    "Peer version with pre-release tag greater than min version",
+			minVer:  "1.0.0",
+			peerVer: "1.0.1-alpha",
+			want:    true,
+			wantErr: false,
+		},
+		{
+			name:    "Invalid peer version format",
+			minVer:  "1.0.0",
+			peerVer: "dev",
+			want:    false,
+			wantErr: true,
+		},
+		{
+			name:    "Invalid min version format",
+			minVer:  "invalid.version",
+			peerVer: "1.0.0",
+			want:    false,
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := MeetsMinVersion(tt.minVer, tt.peerVer)
+			if tt.wantErr {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
diff --git a/version/version.go b/version/version.go
index b92e5ac7e..074305bd6 100644
--- a/version/version.go
+++ b/version/version.go
@@ -71,30 +71,6 @@ func NetbirdCommit() string {
 	return revision
 }
 
-// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.)
-func sanitizeVersion(version string) string {
-	parts := strings.Split(version, "-")
-	return parts[0]
-}
-
-// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version
-func MeetsMinVersion(minVer, peerVer string) (bool, error) {
-	peerVer = sanitizeVersion(peerVer)
-	minVer = sanitizeVersion(minVer)
-
-	peerNBVer, err := v.NewVersion(peerVer)
-	if err != nil {
-		return false, err
-	}
-
-	constraints, err := v.NewConstraint(">= " + minVer)
-	if err != nil {
-		return false, err
-	}
-
-	return constraints.Check(peerNBVer), nil
-}
-
 // IsDevelopmentVersion reports whether the given version string identifies
 // a non-release / development build. It is the single source of truth for
 // "is this a dev build" checks across the codebase; use it instead of
diff --git a/version/version_test.go b/version/version_test.go
index f05bcbd87..cdba6b804 100644
--- a/version/version_test.go
+++ b/version/version_test.go
@@ -1,10 +1,6 @@
 package version
 
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
+import "testing"
 
 func TestIsDevelopmentVersion(t *testing.T) {
 	tests := []struct {
@@ -30,68 +26,3 @@ func TestIsDevelopmentVersion(t *testing.T) {
 		})
 	}
 }
-
-func TestMeetsMinVersion(t *testing.T) {
-	tests := []struct {
-		name    string
-		minVer  string
-		peerVer string
-		want    bool
-		wantErr bool
-	}{
-		{
-			name:    "Peer version greater than min version",
-			minVer:  "0.26.0",
-			peerVer: "0.60.1",
-			want:    true,
-			wantErr: false,
-		},
-		{
-			name:    "Peer version equals min version",
-			minVer:  "1.0.0",
-			peerVer: "1.0.0",
-			want:    true,
-			wantErr: false,
-		},
-		{
-			name:    "Peer version less than min version",
-			minVer:  "1.0.0",
-			peerVer: "0.9.9",
-			want:    false,
-			wantErr: false,
-		},
-		{
-			name:    "Peer version with pre-release tag greater than min version",
-			minVer:  "1.0.0",
-			peerVer: "1.0.1-alpha",
-			want:    true,
-			wantErr: false,
-		},
-		{
-			name:    "Invalid peer version format",
-			minVer:  "1.0.0",
-			peerVer: "dev",
-			want:    false,
-			wantErr: true,
-		},
-		{
-			name:    "Invalid min version format",
-			minVer:  "invalid.version",
-			peerVer: "1.0.0",
-			want:    false,
-			wantErr: true,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got, err := MeetsMinVersion(tt.minVer, tt.peerVer)
-			if tt.wantErr {
-				assert.Error(t, err)
-			} else {
-				assert.NoError(t, err)
-			}
-			assert.Equal(t, tt.want, got)
-		})
-	}
-}

From 63c26be72faf2d4b69f268fd69e5fb4955b1d04c Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:53:06 +0900
Subject: [PATCH 12/40] [client] Add local Prometheus metrics endpoint (#6689)

---
 client/cmd/root.go                            |    7 +
 client/cmd/up.go                              |  128 +-
 client/internal/debug/debug.go                |    2 +
 client/internal/localmetrics/localmetrics.go  |  274 ++++
 .../localmetrics/localmetrics_test.go         |  151 +++
 client/internal/metrics/influxdb.go           |   23 +-
 client/internal/metrics/metrics.go            |   23 +
 client/internal/metrics/metrics_default.go    |   16 +-
 client/internal/metrics/prometheus.go         |  119 ++
 client/internal/peer/status.go                |   12 +
 client/internal/peer/status_test.go           |   22 +
 client/internal/profilemanager/config.go      |   26 +
 .../profilemanager/config_mdm_test.go         |   26 +
 client/mdm/canonical_loaders.go               |    2 +
 client/mdm/canonical_loaders_test.go          |   52 +
 client/mdm/policy.go                          |    2 +
 client/proto/daemon.pb.go                     |   52 +-
 client/proto/daemon.proto                     |    6 +
 client/server/mdm.go                          |   30 +-
 client/server/server.go                       |   39 +-
 client/server/setconfig_mdm_test.go           |   45 +
 client/server/setconfig_test.go               |   10 +
 client/server/ssh_gate.go                     |   81 +-
 client/server/ssh_gate_test.go                |   98 ++
 go.mod                                        |    3 +-
 .../grafana/dashboards/client.json            | 1107 +++++++++++++++++
 26 files changed, 2265 insertions(+), 91 deletions(-)
 create mode 100644 client/internal/localmetrics/localmetrics.go
 create mode 100644 client/internal/localmetrics/localmetrics_test.go
 create mode 100644 client/internal/metrics/prometheus.go
 create mode 100644 client/mdm/canonical_loaders_test.go
 create mode 100644 infrastructure_files/observability/grafana/dashboards/client.json

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/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/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/localmetrics/localmetrics.go b/client/internal/localmetrics/localmetrics.go
new file mode 100644
index 000000000..f829fa132
--- /dev/null
+++ b/client/internal/localmetrics/localmetrics.go
@@ -0,0 +1,274 @@
+// Package localmetrics exposes client connection state as a local
+// Prometheus /metrics endpoint.
+package localmetrics
+
+import (
+	"context"
+	"errors"
+	"net"
+	"net/http"
+	"net/netip"
+	"sync"
+	"time"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+	dto "github.com/prometheus/client_model/go"
+	log "github.com/sirupsen/logrus"
+
+	"github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// DefaultListenAddress is used when local metrics are enabled without an explicit address.
+const DefaultListenAddress = "127.0.0.1:9191"
+
+const (
+	shutdownTimeout   = 3 * time.Second
+	readHeaderTimeout = 5 * time.Second
+	readTimeout       = 10 * time.Second
+	writeTimeout      = 30 * time.Second
+	idleTimeout       = time.Minute
+)
+
+// statusSource provides the connection state snapshots the collector reads on scrape.
+type statusSource interface {
+	GetPeerStates() []peer.State
+	GetManagementState() peer.ManagementState
+	GetSignalState() peer.SignalState
+}
+
+// GathererProvider returns the current client metrics gatherer, or nil when
+// no engine is running. It is called on every scrape.
+type GathererProvider func() prometheus.Gatherer
+
+// Manager runs the local /metrics HTTP endpoint according to the active
+// client configuration. Reconcile is safe to call on every config change.
+type Manager struct {
+	status        statusSource
+	clientMetrics GathererProvider
+
+	mu   sync.Mutex
+	srv  *http.Server
+	addr string
+}
+
+// NewManager creates a manager that serves metrics from status and
+// clientMetrics and shuts down when ctx is canceled.
+func NewManager(ctx context.Context, status statusSource, clientMetrics GathererProvider) *Manager {
+	m := &Manager{status: status, clientMetrics: clientMetrics}
+	go func() {
+		<-ctx.Done()
+		m.Stop()
+	}()
+	return m
+}
+
+// Reconcile starts, stops, or restarts the metrics endpoint to match the
+// desired state. An empty addr falls back to DefaultListenAddress.
+func (m *Manager) Reconcile(enabled bool, addr string) {
+	if addr == "" {
+		addr = DefaultListenAddress
+	}
+	warnIfNotLoopback(addr)
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if !enabled {
+		m.stop()
+		return
+	}
+	if m.srv != nil && m.addr == addr {
+		return
+	}
+	m.stop()
+
+	registry := prometheus.NewRegistry()
+	registry.MustRegister(newCollector(m.status))
+
+	gatherers := prometheus.Gatherers{registry, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) {
+		if m.clientMetrics == nil {
+			return nil, nil
+		}
+		g := m.clientMetrics()
+		if g == nil {
+			return nil, nil
+		}
+		return g.Gather()
+	})}
+
+	mux := http.NewServeMux()
+	mux.Handle("/metrics", promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{}))
+
+	srv := &http.Server{
+		Addr:              addr,
+		Handler:           mux,
+		ReadHeaderTimeout: readHeaderTimeout,
+		ReadTimeout:       readTimeout,
+		WriteTimeout:      writeTimeout,
+		IdleTimeout:       idleTimeout,
+	}
+	m.srv = srv
+	m.addr = addr
+
+	log.Infof("serving local metrics on http://%s/metrics", addr)
+	go func() {
+		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+			log.Errorf("failed to serve local metrics on %s: %v", addr, err)
+			m.clear(srv)
+		}
+	}()
+}
+
+// clear drops the reference to srv so a later Reconcile with the same
+// address restarts it. A newer server may already have replaced it, in
+// which case the reference must stay.
+func (m *Manager) clear(srv *http.Server) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if m.srv != srv {
+		return
+	}
+	m.srv = nil
+	m.addr = ""
+}
+
+// Stop shuts down the metrics endpoint if it is running.
+func (m *Manager) Stop() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.stop()
+}
+
+// stop shuts down the running server. Callers must hold m.mu.
+func (m *Manager) stop() {
+	if m.srv == nil {
+		return
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+	defer cancel()
+	if err := m.srv.Shutdown(ctx); err != nil {
+		log.Debugf("failed to shut down local metrics server: %v", err)
+	}
+	m.srv = nil
+	m.addr = ""
+}
+
+// collector converts status recorder snapshots into Prometheus metrics at scrape time.
+type collector struct {
+	status statusSource
+
+	managementConnected *prometheus.Desc
+	signalConnected     *prometheus.Desc
+	peersTotal          *prometheus.Desc
+	peersConnected      *prometheus.Desc
+	peerLatency         *prometheus.Desc
+}
+
+func newCollector(status statusSource) *collector {
+	return &collector{
+		status: status,
+		managementConnected: prometheus.NewDesc(
+			"netbird_management_connected",
+			"Whether the client is connected to the management service (1 connected, 0 disconnected).",
+			nil, nil,
+		),
+		signalConnected: prometheus.NewDesc(
+			"netbird_signal_connected",
+			"Whether the client is connected to the signal service (1 connected, 0 disconnected).",
+			nil, nil,
+		),
+		peersTotal: prometheus.NewDesc(
+			"netbird_peers",
+			"Number of peers known to this client.",
+			nil, nil,
+		),
+		peersConnected: prometheus.NewDesc(
+			"netbird_peers_connected",
+			"Number of connected peers by connection type.",
+			[]string{"connection_type"}, nil,
+		),
+		peerLatency: prometheus.NewDesc(
+			"netbird_peer_latency_seconds",
+			"Round-trip latency per directly connected peer; relayed connections have no latency measurement.",
+			[]string{"peer"}, nil,
+		),
+	}
+}
+
+// Describe implements prometheus.Collector.
+func (c *collector) Describe(ch chan<- *prometheus.Desc) {
+	ch <- c.managementConnected
+	ch <- c.signalConnected
+	ch <- c.peersTotal
+	ch <- c.peersConnected
+	ch <- c.peerLatency
+}
+
+// Collect implements prometheus.Collector.
+func (c *collector) Collect(ch chan<- prometheus.Metric) {
+	ch <- prometheus.MustNewConstMetric(c.managementConnected, prometheus.GaugeValue, boolToFloat(c.status.GetManagementState().Connected))
+	ch <- prometheus.MustNewConstMetric(c.signalConnected, prometheus.GaugeValue, boolToFloat(c.status.GetSignalState().Connected))
+
+	peers := c.status.GetPeerStates()
+	ch <- prometheus.MustNewConstMetric(c.peersTotal, prometheus.GaugeValue, float64(len(peers)))
+
+	var p2p, relayed float64
+	for _, p := range peers {
+		if p.ConnStatus != peer.StatusConnected {
+			continue
+		}
+		if p.Relayed {
+			relayed++
+			continue
+		}
+		p2p++
+
+		if latency := p.Latency.Seconds(); latency > 0 {
+			ch <- prometheus.MustNewConstMetric(c.peerLatency, prometheus.GaugeValue, latency, p.FQDN)
+		}
+	}
+	ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, p2p, "p2p")
+	ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, relayed, "relay")
+}
+
+func boolToFloat(b bool) float64 {
+	if b {
+		return 1
+	}
+	return 0
+}
+
+// IsLoopback reports whether addr binds the endpoint to the local host only.
+// An empty address means DefaultListenAddress. It fails closed: an address
+// that cannot be confirmed loopback, including an unparseable one, is not.
+func IsLoopback(addr string) bool {
+	if addr == "" {
+		addr = DefaultListenAddress
+	}
+
+	host, _, err := net.SplitHostPort(addr)
+	if err != nil {
+		return false
+	}
+	if host == "localhost" {
+		return true
+	}
+
+	ip, err := netip.ParseAddr(host)
+	if err != nil {
+		return false
+	}
+	return ip.Unmap().IsLoopback()
+}
+
+// warnIfNotLoopback logs a warning when the listen address cannot be
+// confirmed to be local-only, since the endpoint exposes peer and
+// connectivity details without authentication.
+func warnIfNotLoopback(addr string) {
+	if IsLoopback(addr) {
+		return
+	}
+	log.Warnf("local metrics endpoint listens on non-loopback address %s and is reachable from the network without authentication", addr)
+}
diff --git a/client/internal/localmetrics/localmetrics_test.go b/client/internal/localmetrics/localmetrics_test.go
new file mode 100644
index 000000000..727137077
--- /dev/null
+++ b/client/internal/localmetrics/localmetrics_test.go
@@ -0,0 +1,151 @@
+package localmetrics
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/prometheus/client_golang/prometheus/testutil"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/client/internal/peer"
+)
+
+type stubStatus struct {
+	peers      []peer.State
+	management peer.ManagementState
+	signal     peer.SignalState
+}
+
+func (s *stubStatus) GetPeerStates() []peer.State              { return s.peers }
+func (s *stubStatus) GetManagementState() peer.ManagementState { return s.management }
+func (s *stubStatus) GetSignalState() peer.SignalState         { return s.signal }
+
+func testStatus() *stubStatus {
+	return &stubStatus{
+		management: peer.ManagementState{Connected: true},
+		signal:     peer.SignalState{Connected: true},
+		peers: []peer.State{
+			{FQDN: "peer-a.netbird.cloud", IP: "100.90.0.1", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 12 * time.Millisecond},
+			{FQDN: "peer-b.netbird.cloud", IP: "100.90.0.2", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 36 * time.Millisecond},
+			{FQDN: "peer-c.netbird.cloud", IP: "100.90.0.3", ConnStatus: peer.StatusConnected, Relayed: true},
+			{FQDN: "peer-d.netbird.cloud", IP: "100.90.0.4", ConnStatus: peer.StatusIdle},
+		},
+	}
+}
+
+func TestCollector(t *testing.T) {
+	c := newCollector(testStatus())
+
+	expected := `
+# HELP netbird_management_connected Whether the client is connected to the management service (1 connected, 0 disconnected).
+# TYPE netbird_management_connected gauge
+netbird_management_connected 1
+# HELP netbird_peer_latency_seconds Round-trip latency per directly connected peer; relayed connections have no latency measurement.
+# TYPE netbird_peer_latency_seconds gauge
+netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012
+netbird_peer_latency_seconds{peer="peer-b.netbird.cloud"} 0.036
+# HELP netbird_peers Number of peers known to this client.
+# TYPE netbird_peers gauge
+netbird_peers 4
+# HELP netbird_peers_connected Number of connected peers by connection type.
+# TYPE netbird_peers_connected gauge
+netbird_peers_connected{connection_type="p2p"} 2
+netbird_peers_connected{connection_type="relay"} 1
+# HELP netbird_signal_connected Whether the client is connected to the signal service (1 connected, 0 disconnected).
+# TYPE netbird_signal_connected gauge
+netbird_signal_connected 1
+`
+	require.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(expected)))
+}
+
+func TestServe(t *testing.T) {
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err, "must find a free port")
+	addr := ln.Addr().String()
+	require.NoError(t, ln.Close())
+
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+	m := NewManager(ctx, testStatus(), nil)
+	m.Reconcile(true, addr)
+
+	var body string
+	require.Eventually(t, func() bool {
+		resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr))
+		if err != nil {
+			return false
+		}
+		defer resp.Body.Close()
+		data, err := io.ReadAll(resp.Body)
+		if err != nil || resp.StatusCode != http.StatusOK {
+			return false
+		}
+		body = string(data)
+		return true
+	}, 2*time.Second, 50*time.Millisecond, "metrics endpoint should come up")
+
+	assert.Contains(t, body, "netbird_peers 4")
+	assert.Contains(t, body, `netbird_peers_connected{connection_type="relay"} 1`)
+	assert.Contains(t, body, `netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012`)
+}
+
+// A server that never came up must not be remembered, otherwise reconciling the
+// same address again is a no-op and the endpoint never recovers.
+func TestReconcileForgetsAFailedServer(t *testing.T) {
+	blocker, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err, "must find a free port")
+	t.Cleanup(func() { _ = blocker.Close() })
+	addr := blocker.Addr().String()
+
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+	m := NewManager(ctx, testStatus(), nil)
+	m.Reconcile(true, addr)
+
+	require.Eventually(t, func() bool {
+		m.mu.Lock()
+		defer m.mu.Unlock()
+		return m.srv == nil && m.addr == ""
+	}, 2*time.Second, 20*time.Millisecond, "the failed server should be dropped")
+
+	require.NoError(t, blocker.Close())
+	m.Reconcile(true, addr)
+
+	require.Eventually(t, func() bool {
+		resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr))
+		if err != nil {
+			return false
+		}
+		defer resp.Body.Close()
+		return resp.StatusCode == http.StatusOK
+	}, 2*time.Second, 50*time.Millisecond, "reconciling the same address should retry the bind")
+}
+
+func TestIsLoopback(t *testing.T) {
+	tests := map[string]bool{
+		"":                        true,
+		"127.0.0.1:9191":          true,
+		"127.9.9.9:9191":          true,
+		"[::1]:9191":              true,
+		"[::ffff:127.0.0.1]:9191": true,
+		"localhost:9191":          true,
+		"0.0.0.0:9191":            false,
+		"[::]:9191":               false,
+		"192.168.1.10:9191":       false,
+		"not-an-address":          false,
+		"example.com:9191":        false,
+	}
+
+	for addr, want := range tests {
+		t.Run(addr, func(t *testing.T) {
+			assert.Equal(t, want, IsLoopback(addr), "loopback verdict for %q", addr)
+		})
+	}
+}
diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go
index 4ba14bf44..717544f6a 100644
--- a/client/internal/metrics/influxdb.go
+++ b/client/internal/metrics/influxdb.go
@@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages(
 	isReconnection bool,
 	timestamps ConnectionStageTimestamps,
 ) {
-	var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64
-
-	if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() {
-		signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds()
-	}
-
-	if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
-		connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds()
-	}
-
-	if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
-		totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds()
-	}
-
-	attemptType := "initial"
-	if isReconnection {
-		attemptType = "reconnection"
-	}
+	signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations()
 
 	connTypeStr := connectionType.String()
 	tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s",
 		agentInfo.DeploymentType.String(),
 		connTypeStr,
-		attemptType,
+		attemptType(isReconnection),
 		agentInfo.Version,
 		agentInfo.OS,
 		agentInfo.Arch,
@@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages(
 	m.trimLocked()
 
 	log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs",
-		agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
+		agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
 }
 
 func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) {
diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go
index cfe477107..5edf1d9c7 100644
--- a/client/internal/metrics/metrics.go
+++ b/client/internal/metrics/metrics.go
@@ -89,6 +89,21 @@ type ConnectionStageTimestamps struct {
 	WgHandshakeSuccess time.Time
 }
 
+// Durations returns the stage durations in seconds. A duration is zero when
+// either of its timestamps is missing.
+func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) {
+	if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() {
+		signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds()
+	}
+	if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() {
+		connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds()
+	}
+	if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() {
+		total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds()
+	}
+	return signalingToConnection, connectionToWgHandshake, total
+}
+
 // String returns a human-readable representation of the connection stage timestamps
 func (c ConnectionStageTimestamps) String() string {
 	return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}",
@@ -279,3 +294,11 @@ func (c *ClientMetrics) stopPushLocked() {
 	c.wg.Wait()
 	c.push.Store(nil)
 }
+
+// attemptType returns the metric label for an initial vs reconnection attempt.
+func attemptType(isReconnection bool) string {
+	if isReconnection {
+		return "reconnection"
+	}
+	return "initial"
+}
diff --git a/client/internal/metrics/metrics_default.go b/client/internal/metrics/metrics_default.go
index 927ab51d1..3798adab6 100644
--- a/client/internal/metrics/metrics_default.go
+++ b/client/internal/metrics/metrics_default.go
@@ -2,10 +2,24 @@
 
 package metrics
 
+import "github.com/prometheus/client_golang/prometheus"
+
 // NewClientMetrics creates a new ClientMetrics instance
 func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics {
 	return &ClientMetrics{
-		impl:      newInfluxDBMetrics(),
+		impl:      newPrometheusMetrics(newInfluxDBMetrics()),
 		agentInfo: agentInfo,
 	}
 }
+
+// PrometheusGatherer returns the registry with the mirrored Prometheus
+// metrics, or nil when unavailable.
+func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer {
+	if c == nil {
+		return nil
+	}
+	if pm, ok := c.impl.(*prometheusMetrics); ok {
+		return pm.Gatherer()
+	}
+	return nil
+}
diff --git a/client/internal/metrics/prometheus.go b/client/internal/metrics/prometheus.go
new file mode 100644
index 000000000..7f5020ea9
--- /dev/null
+++ b/client/internal/metrics/prometheus.go
@@ -0,0 +1,119 @@
+//go:build !js
+
+package metrics
+
+import (
+	"context"
+	"io"
+	"strconv"
+	"time"
+
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+// prometheusMetrics mirrors recorded client metrics into a Prometheus
+// registry for the local /metrics endpoint, then delegates to the wrapped
+// implementation. Export and Reset pass through untouched: Prometheus
+// metrics are cumulative and pull-based.
+type prometheusMetrics struct {
+	next     metricsImplementation
+	registry *prometheus.Registry
+
+	connectionStages  *prometheus.HistogramVec
+	syncDuration      prometheus.Histogram
+	syncPhaseDuration *prometheus.HistogramVec
+	loginDuration     *prometheus.HistogramVec
+}
+
+func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics {
+	connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60}
+
+	m := &prometheusMetrics{
+		next:     next,
+		registry: prometheus.NewRegistry(),
+		connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+			Name:    "netbird_peer_connection_stage_duration_seconds",
+			Help:    "Duration of peer connection establishment stages.",
+			Buckets: connectionBuckets,
+		}, []string{"stage", "connection_type", "attempt_type"}),
+		syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
+			Name:    "netbird_sync_duration_seconds",
+			Help:    "Duration of management sync message processing.",
+			Buckets: prometheus.DefBuckets,
+		}),
+		syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+			Name:    "netbird_sync_phase_duration_seconds",
+			Help:    "Duration of individual sync processing phases.",
+			Buckets: prometheus.DefBuckets,
+		}, []string{"phase"}),
+		loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+			Name:    "netbird_login_duration_seconds",
+			Help:    "Duration of logins to the management service.",
+			Buckets: prometheus.DefBuckets,
+		}, []string{"success"}),
+	}
+
+	m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration)
+	return m
+}
+
+// Gatherer returns the registry holding the mirrored metrics.
+func (m *prometheusMetrics) Gatherer() prometheus.Gatherer {
+	return m.registry
+}
+
+// RecordConnectionStages implements metricsImplementation.
+func (m *prometheusMetrics) RecordConnectionStages(
+	ctx context.Context,
+	agentInfo AgentInfo,
+	connectionPairID string,
+	connectionType ConnectionType,
+	isReconnection bool,
+	timestamps ConnectionStageTimestamps,
+) {
+	attempt := attemptType(isReconnection)
+	connType := connectionType.String()
+
+	signalingToConnection, connectionToWgHandshake, total := timestamps.Durations()
+	if signalingToConnection > 0 {
+		m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection)
+	}
+	if connectionToWgHandshake > 0 {
+		m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake)
+	}
+	if total > 0 {
+		m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total)
+	}
+
+	m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps)
+}
+
+// RecordSyncDuration implements metricsImplementation.
+func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) {
+	m.syncDuration.Observe(duration.Seconds())
+	m.next.RecordSyncDuration(ctx, agentInfo, duration)
+}
+
+// RecordSyncPhase implements metricsImplementation.
+func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
+	m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds())
+	m.next.RecordSyncPhase(ctx, agentInfo, phase, duration)
+}
+
+// RecordLoginDuration implements metricsImplementation.
+func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
+	m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds())
+	m.next.RecordLoginDuration(ctx, agentInfo, duration, success)
+}
+
+// Export implements metricsImplementation by delegating to the wrapped
+// implementation; Prometheus metrics are pulled via the registry instead.
+func (m *prometheusMetrics) Export(w io.Writer) error {
+	return m.next.Export(w)
+}
+
+// Reset implements metricsImplementation by delegating to the wrapped
+// implementation; Prometheus metrics must not be cleared on push.
+func (m *prometheusMetrics) Reset() {
+	m.next.Reset()
+}
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index 24e3e7fac..bf36b944b 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -1167,6 +1167,18 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo
 	return maps.Clone(d.resolvedDomainsStates)
 }
 
+// GetPeerStates returns a snapshot of all known peer states, including offline peers.
+func (d *Status) GetPeerStates() []State {
+	d.mux.RLock()
+	defer d.mux.RUnlock()
+
+	states := make([]State, 0, d.numOfPeers())
+	for _, state := range d.peers {
+		states = append(states, state)
+	}
+	return append(states, d.offlinePeers...)
+}
+
 // GetFullStatus gets full status
 func (d *Status) GetFullStatus() FullStatus {
 	fullStatus := FullStatus{
diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go
index 29404d413..82dff0d6f 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -129,6 +129,28 @@ func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) {
 	req.False(ok, "removed peer must not resolve by IPv6 tunnel address")
 }
 
+// TestStatus_GetPeerStates_IncludesOfflinePeers keeps the snapshot in line with
+// GetFullStatus: offline peers are known peers, so a consumer counting peers
+// must see the same total the status command reports.
+func TestStatus_GetPeerStates_IncludesOfflinePeers(t *testing.T) {
+	status := NewRecorder("https://mgm")
+	req := require.New(t)
+
+	req.NoError(status.AddPeer("pk-online", "online.netbird", "100.64.0.10", "fd00::1"))
+	status.ReplaceOfflinePeers([]State{
+		{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", ConnStatus: StatusIdle},
+	})
+
+	states := status.GetPeerStates()
+	req.Len(states, 2, "snapshot must carry both the online and the offline peer")
+
+	keys := make([]string, 0, len(states))
+	for _, s := range states {
+		keys = append(keys, s.PubKey)
+	}
+	req.ElementsMatch([]string{"pk-online", "pk-offline"}, keys, "snapshot must carry both peers")
+}
+
 func TestStatus_UpdatePeerFQDN(t *testing.T) {
 	key := "abc"
 	fqdn := "peer-a.netbird.local"
diff --git a/client/internal/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/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go
index 29288b511..eb9db07c4 100644
--- a/client/mdm/canonical_loaders.go
+++ b/client/mdm/canonical_loaders.go
@@ -27,6 +27,8 @@ var allKeys = []string{
 	KeyRosenpassEnabled,
 	KeyRosenpassPermissive,
 	KeyWireguardPort,
+	KeyEnableLocalMetrics,
+	KeyLocalMetricsAddress,
 	KeySplitTunnelMode,
 	KeySplitTunnelApps,
 	KeyLazyConnection,
diff --git a/client/mdm/canonical_loaders_test.go b/client/mdm/canonical_loaders_test.go
new file mode 100644
index 000000000..330a15c47
--- /dev/null
+++ b/client/mdm/canonical_loaders_test.go
@@ -0,0 +1,52 @@
+//go:build windows || darwin
+
+package mdm
+
+import (
+	"go/ast"
+	"go/parser"
+	"go/token"
+	"slices"
+	"strconv"
+	"testing"
+)
+
+// TestAllKeysCoversEveryPolicyKey guards against the drift that adding a Key*
+// constant without listing it in allKeys causes: the desktop loaders resolve
+// value names through canonicalKey, so an unlisted key is silently discarded as
+// unknown. policy.go is parsed rather than hand-mirrored so the test cannot go
+// stale in the same way.
+func TestAllKeysCoversEveryPolicyKey(t *testing.T) {
+	file, err := parser.ParseFile(token.NewFileSet(), "policy.go", nil, 0)
+	if err != nil {
+		t.Fatalf("parse policy.go: %v", err)
+	}
+
+	for _, decl := range file.Decls {
+		gen, ok := decl.(*ast.GenDecl)
+		if !ok || gen.Tok != token.CONST {
+			continue
+		}
+		for _, spec := range gen.Specs {
+			value, ok := spec.(*ast.ValueSpec)
+			if !ok || len(value.Names) != 1 || len(value.Values) != 1 {
+				continue
+			}
+			name := value.Names[0].Name
+			if len(name) < 4 || name[:3] != "Key" {
+				continue
+			}
+			lit, ok := value.Values[0].(*ast.BasicLit)
+			if !ok || lit.Kind != token.STRING {
+				continue
+			}
+			key, err := strconv.Unquote(lit.Value)
+			if err != nil {
+				t.Fatalf("unquote %s: %v", name, err)
+			}
+			if !slices.Contains(allKeys, key) {
+				t.Errorf("%s (%q) is missing from allKeys, so the desktop loaders discard it as unknown", name, key)
+			}
+		}
+	}
+}
diff --git a/client/mdm/policy.go b/client/mdm/policy.go
index 1feff28f8..6c64acfc8 100644
--- a/client/mdm/policy.go
+++ b/client/mdm/policy.go
@@ -47,6 +47,8 @@ const (
 	KeyRosenpassEnabled    = "rosenpassEnabled"
 	KeyRosenpassPermissive = "rosenpassPermissive"
 	KeyWireguardPort       = "wireguardPort"
+	KeyEnableLocalMetrics  = "enableLocalMetrics"
+	KeyLocalMetricsAddress = "localMetricsAddress"
 
 	// Split tunnel is modeled as a single conceptual policy with two
 	// registry/plist values. KeySplitTunnelMode is the discriminator
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index b438a310a..089f3b95b 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -343,6 +343,8 @@ type LoginRequest struct {
 	DisableSSHAuth                *bool   `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
 	SshJWTCacheTTL                *int32  `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
 	DisableIpv6                   *bool   `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
+	EnableLocalMetrics            *bool   `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
+	LocalMetricsAddress           *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
 	unknownFields                 protoimpl.UnknownFields
 	sizeCache                     protoimpl.SizeCache
 }
@@ -658,6 +660,20 @@ func (x *LoginRequest) GetDisableIpv6() bool {
 	return false
 }
 
+func (x *LoginRequest) GetEnableLocalMetrics() bool {
+	if x != nil && x.EnableLocalMetrics != nil {
+		return *x.EnableLocalMetrics
+	}
+	return false
+}
+
+func (x *LoginRequest) GetLocalMetricsAddress() string {
+	if x != nil && x.LocalMetricsAddress != nil {
+		return *x.LocalMetricsAddress
+	}
+	return ""
+}
+
 type LoginResponse struct {
 	state                   protoimpl.MessageState `protogen:"open.v1"`
 	NeedsSSOLogin           bool                   `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"`
@@ -4233,6 +4249,8 @@ type SetConfigRequest struct {
 	DisableSSHAuth                *bool                `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
 	SshJWTCacheTTL                *int32               `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
 	DisableIpv6                   *bool                `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
+	EnableLocalMetrics            *bool                `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
+	LocalMetricsAddress           *string              `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
 	unknownFields                 protoimpl.UnknownFields
 	sizeCache                     protoimpl.SizeCache
 }
@@ -4512,6 +4530,20 @@ func (x *SetConfigRequest) GetDisableIpv6() bool {
 	return false
 }
 
+func (x *SetConfigRequest) GetEnableLocalMetrics() bool {
+	if x != nil && x.EnableLocalMetrics != nil {
+		return *x.EnableLocalMetrics
+	}
+	return false
+}
+
+func (x *SetConfigRequest) GetLocalMetricsAddress() string {
+	if x != nil && x.LocalMetricsAddress != nil {
+		return *x.LocalMetricsAddress
+	}
+	return ""
+}
+
 type SetConfigResponse struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	unknownFields protoimpl.UnknownFields
@@ -7032,7 +7064,7 @@ var File_daemon_proto protoreflect.FileDescriptor
 const file_daemon_proto_rawDesc = "" +
 	"\n" +
 	"\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" +
-	"\fEmptyRequest\"\xef\x12\n" +
+	"\fEmptyRequest\"\x92\x14\n" +
 	"\fLoginRequest\x12\x1a\n" +
 	"\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" +
 	"\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" +
@@ -7077,7 +7109,9 @@ const file_daemon_proto_rawDesc = "" +
 	"\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
 	"\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
 	"\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
-	"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" +
+	"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" +
+	"\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" +
+	"\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
 	"\x11_rosenpassEnabledB\x10\n" +
 	"\x0e_interfaceNameB\x10\n" +
 	"\x0e_wireguardPortB\x17\n" +
@@ -7105,7 +7139,9 @@ const file_daemon_proto_rawDesc = "" +
 	"\x1e_enableSSHRemotePortForwardingB\x11\n" +
 	"\x0f_disableSSHAuthB\x11\n" +
 	"\x0f_sshJWTCacheTTLB\x0f\n" +
-	"\r_disable_ipv6\"\xb5\x01\n" +
+	"\r_disable_ipv6B\x17\n" +
+	"\x15_enable_local_metricsB\x18\n" +
+	"\x16_local_metrics_address\"\xb5\x01\n" +
 	"\rLoginResponse\x12$\n" +
 	"\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" +
 	"\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" +
@@ -7400,7 +7436,7 @@ const file_daemon_proto_rawDesc = "" +
 	"\f_profileNameB\v\n" +
 	"\t_username\"'\n" +
 	"\x15SwitchProfileResponse\x12\x0e\n" +
-	"\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" +
+	"\x02id\x18\x01 \x01(\tR\x02id\"\xbb\x12\n" +
 	"\x10SetConfigRequest\x12\x1a\n" +
 	"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
 	"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
@@ -7440,7 +7476,9 @@ const file_daemon_proto_rawDesc = "" +
 	"\x1denableSSHRemotePortForwarding\x18  \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
 	"\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
 	"\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
-	"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" +
+	"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" +
+	"\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" +
+	"\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
 	"\x11_rosenpassEnabledB\x10\n" +
 	"\x0e_interfaceNameB\x10\n" +
 	"\x0e_wireguardPortB\x17\n" +
@@ -7465,7 +7503,9 @@ const file_daemon_proto_rawDesc = "" +
 	"\x1e_enableSSHRemotePortForwardingB\x11\n" +
 	"\x0f_disableSSHAuthB\x11\n" +
 	"\x0f_sshJWTCacheTTLB\x0f\n" +
-	"\r_disable_ipv6\"\x13\n" +
+	"\r_disable_ipv6B\x17\n" +
+	"\x15_enable_local_metricsB\x18\n" +
+	"\x16_local_metrics_address\"\x13\n" +
 	"\x11SetConfigResponse\"Q\n" +
 	"\x11AddProfileRequest\x12\x1a\n" +
 	"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index a3e3f4500..ad59a78f8 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -242,6 +242,9 @@ message LoginRequest {
   optional bool disableSSHAuth = 38;
   optional int32 sshJWTCacheTTL = 39;
   optional bool disable_ipv6 = 40;
+
+  optional bool enable_local_metrics = 41;
+  optional string local_metrics_address = 42;
 }
 
 message LoginResponse {
@@ -766,6 +769,9 @@ message SetConfigRequest {
   optional bool disableSSHAuth = 33;
   optional int32 sshJWTCacheTTL = 34;
   optional bool disable_ipv6 = 35;
+
+  optional bool enable_local_metrics = 36;
+  optional string local_metrics_address = 37;
 }
 
 message SetConfigResponse{}
diff --git a/client/server/mdm.go b/client/server/mdm.go
index 9836c6bea..552fba94f 100644
--- a/client/server/mdm.go
+++ b/client/server/mdm.go
@@ -233,6 +233,24 @@ func conflictString(key, got string) conflictCheck {
 	}
 }
 
+// conflictStringPtr is conflictString for optional proto fields, where an
+// explicit empty value is still a request to change the setting. If p is
+// nil the field is treated as matching (no override requested); otherwise
+// the check returns true only when the policy contains the key and its
+// value equals *p.
+func conflictStringPtr(key string, p *string) conflictCheck {
+	return conflictCheck{
+		key: key,
+		check: func(pol *mdm.Policy) bool {
+			if p == nil {
+				return true
+			}
+			want, ok := pol.GetString(key)
+			return ok && want == *p
+		},
+	}
+}
+
 // conflictInt64 builds a conflictCheck for an integer MDM key. If p is
 // nil the field is treated as matching; otherwise the check returns
 // true only when the policy contains the key and its int value equals *p.
@@ -301,6 +319,8 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
 		conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
 		conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
 		conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+		conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
+		conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
 	})
 }
 
@@ -346,7 +366,9 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
 		msg.EnableSSHLocalPortForwarding != nil ||
 		msg.EnableSSHRemotePortForwarding != nil ||
 		msg.DisableSSHAuth != nil ||
-		msg.SshJWTCacheTTL != nil
+		msg.SshJWTCacheTTL != nil ||
+		msg.EnableLocalMetrics != nil ||
+		msg.LocalMetricsAddress != nil
 }
 
 // loginRequestHasConfigOverrides reports whether the LoginRequest
@@ -381,7 +403,9 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
 		msg.BlockLanAccess != nil ||
 		msg.DisableNotifications != nil ||
 		len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
-		msg.BlockInbound != nil
+		msg.BlockInbound != nil ||
+		msg.EnableLocalMetrics != nil ||
+		msg.LocalMetricsAddress != nil
 }
 
 // loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
@@ -422,6 +446,8 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
 		conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
 		conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
 		conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+		conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
+		conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
 	})
 }
 
diff --git a/client/server/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/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/go.mod b/go.mod
index 09c3df95b..cede9c22d 100644
--- a/go.mod
+++ b/go.mod
@@ -100,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
@@ -250,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
@@ -290,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
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": ""
+}

From 6620219939739eb298a3f98fb43ece7f9306450f Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:18:49 +0200
Subject: [PATCH 13/40] [client] Add catch-all NRPT rule when NetBird is the
 primary DNS resolver (#7071)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* [client] Add catch-all NRPT rule when NetBird is the primary DNS resolver

* Remove obvious comments

* Install the catch-all rule where the adapter's DNS is set

addDNSSetupForAll makes us the peer's main DNS forwarder, and the catch-all NRPT
rule is the other half of that same job: without it the adapter's NameServer only
adds one more resolver to the set Windows queries in parallel. Having the two in
one place says that, where a separate block at the end of applyDNSConfig read as
an afterthought.

The block could not simply move up: removeDNSMatchPolicies deletes the catch-all
key too, so installing the rule before it ran would have had the rule deleted
moments later. The cleanup now runs first, which is what it was always for - it
clears what the previous apply installed before this one installs anything - and
keeps being unconditional, so a leftover rule from an earlier run cannot survive
into a config that no longer wants it.

* Name the escape hatch after the behaviour it restores

NB_DISABLE_DNS_CATCHALL_NRPT described the mechanism it switches off. What an
operator reaching for it wants is the behaviour they had before, so name it that:
NB_USE_LEGACY_DNS_RESOLUTION, matching NB_USE_LEGACY_ROUTING, the only other
legacy switch in the client.

Not NB_WIN_LEGACY_FULL_TUNNEL_DNS_RESOLVE, as first suggested: the rule follows a
primary nameserver group, not a full tunnel, and putting FULL_TUNNEL in a public
variable name would carry that confusion for as long as the variable lives. No
OS prefix either, since nothing else in the client has one and this switch is
inert anywhere but Windows by construction.

Behaviour and default are unchanged: the catch-all rule is on unless the variable
says otherwise.

* Exempt .local from the catch-all rule

RFC 6762 reserves .local for multicast DNS and says unicast resolvers must not
answer for it. The catch-all rule hands it to us anyway, we forward it to
whatever upstream the primary nameserver group points at, and the answer comes
back NXDOMAIN for hosts that do exist - printers, NAS boxes, anything
announcing itself on the link. Confirmed on a Win11Pro VM: laptop.local resolves
with the client down and returns "Nome DNS inesistente" with it up, and the
client log shows the query arriving on the catch-all handler and being forwarded
to 1.1.1.1.

An NRPT rule that names a namespace and lists no servers is an exemption: the
DNS client resolves those names as it would with no rule at all. What that looks
like in the registry is not what it sounds like. Writing no server value and
clearing ConfigOptions produces a rule Windows treats as a no-op - it never
appears in Get-DnsClientNrptPolicy -Effective and the catch-all keeps the query.
The value has to be present and empty, with ConfigOptions still 0x8: the flag
says the server list is the meaningful part of the rule, and an empty list then
means "no server, resolve normally". Verified both encodings on the VM.

Installed together with the catch-all, since without one nothing captures .local
in the first place, and removed with it.

Exclusivity is unaffected elsewhere, and a more specific rule still wins - a
match domain under .local keeps resolving through NetBird, which is what a
legacy Active Directory domain named corp.local needs. Verified separately that
a match domain does take precedence over the catch-all: declaring fritz.box
against the local router restored laptop.fritz.box while the catch-all was in
force.

* Treat the root namespace as a match domain, not a special case

The catch-all had a function, a registry key and a call site of its own, which
made it look like a different mechanism. It is not: "." is an NRPT namespace like
any other, it just happens to match every name. So it goes into the match domain
list, and addDNSMatchPolicy writes it along with the rest — batching, GPO
variant, volatile keys and cleanup all come for free.

The .local exemption stays a rule of its own, and not for symmetry: it is the one
rule with a different server list, an empty one. Putting it in the same Name
value would give it our resolver and exempt nothing.

Windows expands a rule's Name value into one effective namespace each, so a rule
carrying {.example.com, .} still shows both as separate rows in
Get-DnsClientNrptPolicy -Effective. Nothing is lost for diagnosis by dropping the
dedicated key.

Suggested by Vik in review.

* Do not report a failed NRPT cleanup as success

removeRegistryKeyFromDNSPolicyConfig returned nil for every OpenKey error, so a
permission or registry failure was indistinguishable from a key that was never
there. Cleanup then reported success while the rule stayed in force — which is
how a rule outlives the interface it points at and keeps sending every query to
an address that no longer answers.

Distinguish the two, the way listNRPTRuleKeys already does for the policy store
root: a missing key is nothing to do, anything else reaches the caller.

restoreHostDNS now propagates that error instead of logging it. applyDNSConfig
keeps logging on purpose: there we are about to write fresh rules over whatever
survived, while restore is the path where a rule left behind is the whole
problem.

Also addresses review nits on the tests: doc comments on the two added cases,
reported Close and DeleteKey errors so a failed cleanup cannot contaminate the
next registry test, and a context message on the exemption's namespace assertion.
---
 client/internal/dns/host_windows.go      | 134 ++++++++++++++++++++--
 client/internal/dns/host_windows_test.go | 139 +++++++++++++++++++++++
 2 files changed, 261 insertions(+), 12 deletions(-)

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 {

From 89c6e84a41486469c3244d7caed56d3e5db3e1c8 Mon Sep 17 00:00:00 2001
From: Zoltan Papp 
Date: Fri, 28 Aug 2026 09:33:08 +0200
Subject: [PATCH 14/40] [client, ios] Fix context cancellation during restart
 (#7329)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(mobile): stop the client synchronously so a restart cannot inherit a cancelled context

Original finding
----------------
A user reported that leaving home and switching from wifi to cellular killed
all Internet traffic until NetBird was turned off. A debug bundle captured the
failure (iOS, CLI 0.75.0, self-hosted management, generated 2026-08-18 01:17;
the incident is at 2026-08-17 22:37:38-51 UTC).

The bundle shows the whole sequence:

  22:37:38.255  management sync stream drops (keepalive ACK timeout)
  22:37:43.670  Swift: "Network type changed: wifi -> cellular" -> schedules a
                restart with a 1s debounce
  22:37:44.737  Go: "ensuring wg interface is removed, Netbird engine context
                cancelled" - engineCtx dies, every peer gets context canceled
  22:37:49.910  iface.go:238 "failed to remove WireGuard interface utun6:
                timeout when waiting for interface utun6 to be removed"
                -> the teardown stretches out for ~5s
  22:37:50.710  Swift: "restartClient: starting client", needsLogin=false
                (so this is NOT a login expiry)
  22:37:51.013  Go: connect.go:476 "exiting client retry loop due to
                unrecoverable error: context canceled" - the OLD run dies here
  22:37:51.333  Go: grpc.go:135 "failed creating connection to Management
                Service: context canceled" - the NEW start, 2ms after the old
                run finally exited
  22:37:51.334  Swift: "restartClient: start failed" -> widget disconnected
  then nothing for 15 minutes

The tunnel stayed installed with no engine behind it, so every packet was
black-holed. status.txt, generated ~14 hours later, still reads Management:
Disconnected / Signal: Disconnected / Peers count: 0/0 - the client never
recovered on its own.

Root cause
----------
Client.Stop() cancelled a shared ctxCancel field and returned immediately,
without waiting for the run loop to exit. The Swift stop{} completion handler
therefore fired while the Go teardown was still running (stretched out by the
utun6 removal timeout), and the start that followed landed on a context that
the outgoing run was about to cancel.

Two further paths wrote the same shared field. IsLoginRequired() and
LoginForMobile() each overwrote c.ctxCancel, so any call to them during a live
session discarded the running engine's cancel function. restartClient() calls
needsLoginCached() on exactly this path.

Changes
-------
- Stop() now drives the stored ConnectClient: ConnectClient.Stop() cancels the
  run context and blocks on runExited, so the caller's completion handler only
  fires once the run loop has really finished. The ctxCancel path stays as a
  fallback for when no ConnectClient exists yet (e.g. during LoginForMobile).
- Run() owns its cancel in a local variable, so a concurrent call that
  overwrites the shared field can no longer cancel this run's context through
  the deferred cleanup.
- IsLoginRequired() and LoginForMobile() use local cancels and leave the shared
  field alone. LoginForMobile's cancel moves into the deferred cleanup of the
  goroutine that outlives the call, so the OAuth token wait is not cut short.
- The Android SDK gets the same treatment. The structural defect is identical
  there, but the trigger is absent: Android has no automatic engine restart on
  a network type change, and no interface-removal timeout to stretch the
  teardown. This part is preventive, not a fix for an observed failure.

* fix(mobile): do not let a superseded startup publish its client

Review found a window the previous commit left open. Run stored its cancel
function and only published the ConnectClient later, after loading config and
constructing the client. A Stop landing inside that window found no
ConnectClient, cancelled the run and returned immediately. A new Run could then
publish its own client, and the cancelled older run — still executing — would
overwrite it with a client that was already being torn down. The next Stop
stopped that stale client and left the live one running with nothing tracking
it.

Runs now carry a generation. Run claims one before doing any work and publishes
its client only while the generation is still current; a superseded run returns
without touching the shared state. Stop bumps the generation, so any startup
still in flight is invalidated, then cancels it and waits for the run to exit
before returning (20s cap so a wedged teardown cannot block the caller
forever).

setState is gone: publishState replaces it at both call sites on each platform.

* fix(ios): add a non-waiting Stop for callers on a deadline

Stop now waits for the run loop to exit, which is what a restart needs but
wrong for stopTunnel: iOS gives NEPacketTunnelProvider only a few seconds
there before it kills the extension, and the wait can run to its 20s cap.
Waiting past the deadline earns a SIGKILL, so the next start inherits a dirty
state instead of the orderly shutdown the wait was meant to buy.

StopWithoutWait tears the client down and returns. ConnectClient.Stop blocks on
runExited with no cap of its own, so the non-waiting path runs it detached
rather than only skipping the runDone wait.

Android keeps a single blocking Stop: it has no equivalent deadline.

* fix(mobile): guard the run lifecycle with a single lock

Stop and beginRun each touched the same lifecycle state across two locks in
sequence: take stateMu, release it, then take ctxCancelLock. A run starting in
that gap installed its own cancel before Stop reached it, so Stop cancelled the
fresh run and left its own target running — the same class of defect this branch
exists to fix, this time in the locking rather than the state.

ctxCancel moves into the stateMu group, and both sides take their snapshot in
one critical section. ctxCancelLock then guarded nothing and is gone.

* fix(mobile): drop the run-generation machinery for a serialized lifecycle

The platform callers (Swift/Kotlin) always stop before starting and coalesce
restarts, so the generation counter guarded against call patterns that cannot
occur. Replace it with a single-run contract:

- startRun refuses a second Run while the previous one has not exited
- finishRun clears the published state on every exit path, including errors
- Stop cancels and waits for the run loop with a bounded timeout; it no
  longer calls ConnectClient.Stop, whose wait is unbounded
- concurrent Stops wait on the same exit channel instead of returning early
- a superseded startup no longer reports a clean nil exit

* revert(android): drop the run lifecycle changes

Android does not have the defect this PR fixes. On ux/ios-style-redesign the
EngineRestarter is gone: network changes are handled as events instead of an
engine restart, so nothing stops the client and starts it again.

The remaining stop() callers are all final teardowns on the main thread with a
framework deadline - the stop-engine broadcast receiver, onDestroy, onRevoke and
the binder's stopEngine. A Stop that waits for the run loop would risk an ANR
there for a race that cannot occur, so the fix stays iOS-only.

* fix(ios): make loginComplete race-free

The OAuth goroutine spawned by LoginForMobile sets loginComplete after the
call has returned to Swift, while the Swift side polls IsLoginComplete and
later calls ClearLoginComplete from its own thread. The plain bool made all
three unsynchronized: the store may never become visible to the poller, and
a Clear racing the store can be lost, leaving a stale true that makes the
next login look already complete.

Switch the field to atomic.Bool. It is a standalone flag rather than part of
the run lifecycle that stateMu guards, and it has to stay readable while the
login goroutine is still in flight.
---
 client/ios/NetBirdSDK/client.go | 122 ++++++++++++++++++++++++--------
 1 file changed, 93 insertions(+), 29 deletions(-)

diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go
index 8373e498a..bbbb969c9 100644
--- a/client/ios/NetBirdSDK/client.go
+++ b/client/ios/NetBirdSDK/client.go
@@ -4,12 +4,14 @@ package NetBirdSDK
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/netip"
 	"os"
 	"sort"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	log "github.com/sirupsen/logrus"
@@ -37,6 +39,8 @@ const (
 	AnonymizeLevelStrict  = nbAnonymize.LevelStrictString
 )
 
+var errClientAlreadyRunning = errors.New("client is already running")
+
 // RouteListener export internal RouteListener for mobile
 type NetworkChangeListener interface {
 	listener.NetworkChangeListener
@@ -74,15 +78,13 @@ type Client struct {
 	cacheDir              string
 	logFilePath           string
 	recorder              *peer.Status
-	ctxCancel             context.CancelFunc
-	ctxCancelLock         *sync.Mutex
 	deviceName            string
 	osName                string
 	osVersion             string
 	networkChangeListener listener.NetworkChangeListener
 	onHostDnsFn           func([]string)
 	dnsManager            dns.IosDnsManager
-	loginComplete         bool
+	loginComplete         atomic.Bool
 	// netMgr outlives engine restarts: it mirrors the OS connectivity, not
 	// the engine lifecycle. Run injects its state and sweeper into each new
 	// ConnectClient.
@@ -90,9 +92,16 @@ type Client struct {
 	// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
 	preloadedConfig *profilemanager.Config
 
+	// stateMu guards the run lifecycle as one unit: the cancel installed by
+	// the current run, the channel it closes on exit, and the state it
+	// published. One run at a time: startRun refuses a second Run while the
+	// previous one has not exited, and the platform serializes Stop before
+	// Start, so no generation tracking is needed.
 	stateMu       sync.RWMutex
 	connectClient *internal.ConnectClient
 	config        *profilemanager.Config
+	runDone       chan struct{}
+	ctxCancel     context.CancelFunc
 }
 
 // NewClient instantiate a new Client
@@ -107,7 +116,6 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
 		osName:                osName,
 		osVersion:             osVersion,
 		recorder:              recorder,
-		ctxCancelLock:         &sync.Mutex{},
 		networkChangeListener: networkChangeListener,
 		dnsManager:            dnsManager,
 		netMgr:                netevents.NewManager(recorder),
@@ -156,17 +164,21 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
 	c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
 	c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
 
-	var ctx context.Context
 	//nolint
 	ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
 	//nolint
 	ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
 	//nolint
 	ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
-	c.ctxCancelLock.Lock()
-	ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
-	defer c.ctxCancel()
-	c.ctxCancelLock.Unlock()
+	runCtx, runCancel := context.WithCancel(ctxWithValues)
+	defer runCancel()
+
+	done, err := c.startRun(runCancel)
+	if err != nil {
+		return err
+	}
+	defer c.finishRun(done)
+	ctx := runCtx
 
 	// No login pre-flight here. The engine's own loginToManagement (connect.go) performs
 	// the authoritative Login immediately before the first Sync, so a LoginSync() call at
@@ -215,16 +227,40 @@ func (c *Client) NotifyNetworkChange() {
 	c.netMgr.NotifyNetworkChange()
 }
 
-// Stop the internal client and free the resources
+// Stop cancels the running client and waits for the run loop to exit, so a
+// caller that restarts immediately cannot race the outgoing teardown.
 func (c *Client) Stop() {
-	c.ctxCancelLock.Lock()
-	defer c.ctxCancelLock.Unlock()
-	if c.ctxCancel == nil {
+	done := c.cancelRun()
+	if done == nil {
 		return
 	}
 
-	c.ctxCancel()
-	c.setState(nil, nil)
+	select {
+	case <-done:
+	case <-time.After(stopRunWaitTimeout):
+		log.Warnf("Stop: timed out waiting for the run loop to exit")
+	}
+}
+
+// StopWithoutWait cancels the running client without waiting for the run loop.
+// Use it where the caller is on a deadline the wait could overrun, such as
+// NEPacketTunnelProvider.stopTunnel, which iOS gives only a few seconds
+// before it kills the extension.
+func (c *Client) StopWithoutWait() {
+	c.cancelRun()
+}
+
+func (c *Client) cancelRun() chan struct{} {
+	c.stateMu.RLock()
+	done := c.runDone
+	cancel := c.ctxCancel
+	c.stateMu.RUnlock()
+
+	if cancel != nil {
+		cancel()
+	}
+
+	return done
 }
 
 // DebugBundle generates a debug bundle, uploads it and returns the upload key.
@@ -376,16 +412,14 @@ func (c *Client) IsLoginRequiredCached() bool {
 }
 
 func (c *Client) IsLoginRequired() bool {
-	var ctx context.Context
 	//nolint
 	ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
 	//nolint
 	ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
 	//nolint
 	ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
-	c.ctxCancelLock.Lock()
-	defer c.ctxCancelLock.Unlock()
-	ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
+	ctx, cancel := context.WithCancel(ctxWithValues)
+	defer cancel()
 
 	var cfg *profilemanager.Config
 	var err error
@@ -433,17 +467,22 @@ func (c *Client) IsLoginRequired() bool {
 // loginForMobileAuthTimeout is the timeout for requesting auth info from the server
 const loginForMobileAuthTimeout = 30 * time.Second
 
+const stopRunWaitTimeout = 20 * time.Second
+
 func (c *Client) LoginForMobile() string {
-	var ctx context.Context
 	//nolint
 	ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
 	//nolint
 	ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
 	//nolint
 	ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
-	c.ctxCancelLock.Lock()
-	defer c.ctxCancelLock.Unlock()
-	ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
+	ctx, cancel := context.WithCancel(ctxWithValues)
+	loginDone := false
+	defer func() {
+		if !loginDone {
+			cancel()
+		}
+	}()
 
 	// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
 	// which are blocked by the tvOS sandbox in App Group containers
@@ -470,7 +509,9 @@ func (c *Client) LoginForMobile() string {
 	}
 
 	// This could cause a potential race condition with loading the extension which need to be handled on swift side
+	loginDone = true
 	go func() {
+		defer cancel()
 		tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo)
 		if err != nil {
 			log.Errorf("LoginForMobile: WaitToken failed: %v", err)
@@ -487,18 +528,18 @@ func (c *Client) LoginForMobile() string {
 			log.Errorf("LoginForMobile: Login failed: %v", err)
 			return
 		}
-		c.loginComplete = true
+		c.loginComplete.Store(true)
 	}()
 
 	return flowInfo.VerificationURIComplete
 }
 
 func (c *Client) IsLoginComplete() bool {
-	return c.loginComplete
+	return c.loginComplete.Load()
 }
 
 func (c *Client) ClearLoginComplete() {
-	c.loginComplete = false
+	c.loginComplete.Store(false)
 }
 
 func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
@@ -718,13 +759,36 @@ func (c *Client) DeselectRoute(id string) error {
 	return nil
 }
 
-// setState stores the running engine state so DebugBundle can reuse the live
-// config and ConnectClient. It is cleared on Stop.
-func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
+func (c *Client) startRun(cancel context.CancelFunc) (chan struct{}, error) {
 	c.stateMu.Lock()
 	defer c.stateMu.Unlock()
+
+	if c.runDone != nil {
+		return nil, errClientAlreadyRunning
+	}
+
+	done := make(chan struct{})
+	c.runDone = done
+	c.ctxCancel = cancel
+	return done, nil
+}
+
+func (c *Client) finishRun(done chan struct{}) {
+	c.stateMu.Lock()
+	c.connectClient = nil
+	c.config = nil
+	c.runDone = nil
+	c.ctxCancel = nil
+	c.stateMu.Unlock()
+
+	close(done)
+}
+
+func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
+	c.stateMu.Lock()
 	c.config = cfg
 	c.connectClient = cc
+	c.stateMu.Unlock()
 }
 
 // stateSnapshot returns the current config and ConnectClient under the lock.

From 611a9291cd99e4a16f26a68ef28d48bedf0edf40 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:39:48 +0200
Subject: [PATCH 15/40] [management] fix posture check flip evaluation for
 affected peers calc (#7347)

---
 .../network_map/controller/controller.go      |  38 +---
 .../controller/posture_twin_test.go           |  38 ++++
 .../controllers/network_map/interface.go      |   6 +-
 .../controllers/network_map/interface_mock.go |  10 +-
 .../grpc/components_envelope_response.go      |   3 +-
 .../internals/shared/grpc/conversion.go       |   3 +-
 management/internals/shared/grpc/server.go    |  12 +-
 management/server/account.go                  |   3 +-
 management/server/account/manager.go          |  11 +-
 management/server/account/manager_mock.go     |  17 +-
 management/server/account_test.go             |  58 ++++-
 .../affected_peers_router_paths_test.go       |  42 ++++
 .../server/affected_peers_router_test.go      |   6 +
 management/server/mock_server/account_mock.go |  17 +-
 management/server/peer.go                     |  25 ++-
 management/server/peer_posture_test.go        | 183 ++++++++++++++++
 management/server/peer_test.go                |  10 +-
 .../server/posture/affects_posture_test.go    | 202 ------------------
 management/server/posture/checks.go           |  41 ----
 .../server/types/account_networkmapdata.go    |  14 +-
 .../management/networkmap/nmdata/posture.go   |  16 ++
 .../networkmap/nmdata/posture_test.go         |  54 +++++
 22 files changed, 476 insertions(+), 333 deletions(-)
 create mode 100644 management/internals/controllers/network_map/controller/posture_twin_test.go
 create mode 100644 management/server/peer_posture_test.go
 delete mode 100644 management/server/posture/affects_posture_test.go
 create mode 100644 shared/management/networkmap/nmdata/posture_test.go

diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index e74b17638..f21f878a4 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -566,15 +566,13 @@ func NetworkMapFromData(ctx context.Context, nmData *networkmap.NetworkMapData,
 	return nm
 }
 
-// peerPostureChecksFromData mirrors getPeerPostureChecks on the twin store. The
-// sync response only encodes process-check file paths, so only ProcessCheck is
-// converted back to the server posture type.
-func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string) []*posture.Checks {
+// 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]*posture.Checks)
+	peerPostureChecks := make(map[string]*nmdata.PostureChecks)
 	for _, policy := range nmData.Policies {
 		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
 			continue
@@ -583,11 +581,9 @@ func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string)
 			continue
 		}
 		for _, checkID := range policy.SourcePostureChecks {
-			twin := nmData.PostureChecks[checkID]
-			if twin == nil {
-				continue
+			if twin := nmData.PostureChecks[checkID]; twin != nil {
+				peerPostureChecks[checkID] = twin
 			}
-			peerPostureChecks[checkID] = postureChecksFromTwin(twin)
 		}
 	}
 
@@ -608,18 +604,6 @@ func isPeerInPolicySourceGroupsFromData(nmData *networkmap.NetworkMapData, peerI
 	return false
 }
 
-func postureChecksFromTwin(twin *nmdata.PostureChecks) *posture.Checks {
-	checks := &posture.Checks{ID: twin.ID}
-	if twin.Checks.ProcessCheck != nil {
-		processes := make([]posture.Process, 0, len(twin.Checks.ProcessCheck.Processes))
-		for _, p := range twin.Checks.ProcessCheck.Processes {
-			processes = append(processes, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
-		}
-		checks.Checks.ProcessCheck = &posture.ProcessCheck{Processes: processes}
-	}
-	return checks
-}
-
 func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion {
 	if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok {
 		return perAccount
@@ -967,7 +951,7 @@ 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 {
@@ -1032,7 +1016,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
 // getValidatedPeerWithComponentsFromData is the account-free variant of
 // GetValidatedPeerWithComponents. The proxy network map fragment is omitted
 // like on the other nmdata paths.
-func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
+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)
@@ -1142,7 +1126,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 {
@@ -1209,7 +1193,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 // getValidatedPeerWithMapFromData is the account-free variant of
 // GetValidatedPeerWithMap. The proxy network map fragment is omitted like on
 // the other nmdata paths.
-func (c *Controller) getValidatedPeerWithMapFromData(ctx context.Context, accountID string, peerID string, nmData *networkmap.NetworkMapData) (*types.NetworkMap, []*posture.Checks, int64, error) {
+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)
@@ -1234,7 +1218,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 {
@@ -1251,7 +1235,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) {
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..d5c9035e0
--- /dev/null
+++ b/management/internals/controllers/network_map/controller/posture_twin_test.go
@@ -0,0 +1,38 @@
+package controller
+
+import (
+	"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"
+)
+
+func TestPeerPostureChecksFromData_ReturnsTwinsUnchanged(t *testing.T) {
+	check := &nmdata.PostureChecks{
+		ID: "pc1",
+		Checks: nmdata.ChecksDefinition{
+			NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.30.0"},
+			OSVersionCheck: &nmdata.OSVersionCheck{Linux: &nmdata.MinKernelVersionCheck{MinKernelVersion: "6.1"}},
+		},
+	}
+	nmData := &networkmap.NetworkMapData{
+		Groups: map[string]*nmdata.Group{"g1": {ID: "g1", Peers: []string{"peer1"}}},
+		Policies: []*nmdata.Policy{{
+			ID:                  "policy1",
+			Enabled:             true,
+			SourcePostureChecks: []string{"pc1"},
+			Rules:               []*nmdata.PolicyRule{{ID: "rule1", Enabled: true, Sources: []string{"g1"}}},
+		}},
+		PostureChecks: map[string]*nmdata.PostureChecks{"pc1": check},
+	}
+
+	got := peerPostureChecksFromData(nmData, "peer1")
+	require.Len(t, got, 1)
+	assert.Same(t, check, got[0])
+	assert.Len(t, got[0].GetChecks(), 2)
+
+	assert.Empty(t, peerPostureChecksFromData(nmData, "peer-outside-source-group"))
+}
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/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go
index c059b2248..cdd2a7f37 100644
--- a/management/internals/shared/grpc/components_envelope_response.go
+++ b/management/internals/shared/grpc/components_envelope_response.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/netbirdio/netbird/client/ssh/auth"
 	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
-	"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"
@@ -37,7 +36,7 @@ func ToComponentSyncResponse(
 	components *types.NetworkMapComponents,
 	proxyPatch *types.NetworkMap,
 	dnsName string,
-	checks []*posture.Checks,
+	checks []*nmdata.PostureChecks,
 	settings *nmdata.AccountSettingsInfo,
 	extraSettings *types.ExtraSettings,
 	peerGroups []string,
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index 5640127ca..96bd9f1f4 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -18,7 +18,6 @@ import (
 
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
 	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
-	"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"
@@ -154,7 +153,7 @@ func toPeerConfig(peer *nmdata.Peer, network *nmdata.Network, dnsName string, se
 	return peerConfig
 }
 
-func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nmdata.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *nmdata.AccountSettingsInfo, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
+func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nmdata.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*nmdata.PostureChecks, dnsCache *cache.DNSConfigCache, settings *nmdata.AccountSettingsInfo, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
 	// IPv6 data in AllowedIPs and SourcePrefixes wildcard expansion depends on
 	// whether the target peer supports IPv6. Routes and firewall rules are already
 	// filtered at the source (network map builder).
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index 4435f6706..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 {
@@ -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
 
@@ -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/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_test.go b/management/server/account_test.go
index a5a484c1a..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{}, nil)
+	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_router_paths_test.go b/management/server/affected_peers_router_paths_test.go
index 5d83367fd..7fef1ab35 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"
 )
 
@@ -338,3 +339,44 @@ 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) {
+	manager, updateManager := createManagerWithNetworkMapStore(t)
+	s := buildRouterScenario(t, manager, updateManager, true)
+	ctx := context.Background()
+
+	s.createPostureCheckGatedPolicy(t, ctx)
+
+	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..9ecfaed69 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, "")
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/peer.go b/management/server/peer.go
index 579ff2708..87ca57c2b 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -23,7 +23,6 @@ import (
 	"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"
 
@@ -741,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
@@ -1001,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
@@ -1065,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)
@@ -1077,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 {
@@ -1128,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.
@@ -1149,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)
@@ -1322,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)
@@ -1364,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 string, peerGroupIDs []string, policies []*types.Policy) ([]*nmdata.PostureChecks, error) {
 	if len(policies) == 0 {
 		return nil, nil
 	}
@@ -1385,7 +1392,7 @@ 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.
diff --git a/management/server/peer_posture_test.go b/management/server/peer_posture_test.go
new file mode 100644
index 000000000..6b298f5d1
--- /dev/null
+++ b/management/server/peer_posture_test.go
@@ -0,0 +1,183 @@
+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/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))
+}
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index 9a662bdbf..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"
 
@@ -1170,11 +1170,11 @@ 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"}},
 				},
 			},
 		},
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/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go
index 8f2e03a10..d554bfe80 100644
--- a/management/server/types/account_networkmapdata.go
+++ b/management/server/types/account_networkmapdata.go
@@ -93,7 +93,7 @@ func (a *Account) toNetworkMapData(
 	}
 	for _, pc := range a.PostureChecks {
 		if pc != nil {
-			nmd.PostureChecks[pc.ID] = twinPostureChecks(pc)
+			nmd.PostureChecks[pc.ID] = TwinPostureChecks(pc)
 			nmd.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
 		}
 	}
@@ -391,7 +391,17 @@ func TwinNetwork(n *Network) *nmdata.Network {
 	}
 }
 
-func twinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
+// 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
 	}
diff --git a/shared/management/networkmap/nmdata/posture.go b/shared/management/networkmap/nmdata/posture.go
index dc1753791..6a6b028c7 100644
--- a/shared/management/networkmap/nmdata/posture.go
+++ b/shared/management/networkmap/nmdata/posture.go
@@ -45,6 +45,22 @@ func PassesChecks(checks []Check, peer *Peer) bool {
 	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
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))
+}

From 353251d88696255f77be399da696f29f1792ff5d Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:46:42 +0200
Subject: [PATCH 16/40] [management] fix posture check evaluation for direct
 peers in policy definition (#7348)

---
 .../network_map/controller/controller.go      | 17 ++--
 .../controller/posture_twin_test.go           | 78 ++++++++++++-----
 .../policy-direct-peer-unvalidated/case.json  |  5 ++
 .../golden/peer-a.json                        | 64 ++++++++++++++
 .../golden/peer-c.json                        | 64 ++++++++++++++
 .../nmdata.json                               | 63 ++++++++++++++
 .../cases/posture-direct-source/case.json     |  5 ++
 .../posture-direct-source/golden/peer-b.json  | 33 +++++++
 .../posture-direct-source/golden/peer-c.json  | 64 ++++++++++++++
 .../cases/posture-direct-source/nmdata.json   | 51 +++++++++++
 .../affected_peers_router_paths_test.go       | 24 ++++-
 .../server/affected_peers_router_test.go      | 17 ++++
 management/server/peer.go                     | 14 +--
 management/server/peer_posture_test.go        | 20 +++++
 management/server/types/account.go            | 28 +++---
 .../networkmap_components_correctness_test.go | 83 ++++++++++++++++++
 .../networkmap/networkmapcompute.go           | 87 ++++++++++---------
 .../networkmap/networkmapcompute_test.go      | 68 +++++++++++++--
 .../management/types/networkmap_components.go | 32 +++----
 19 files changed, 698 insertions(+), 119 deletions(-)
 create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/case.json
 create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json
 create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json
 create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json
 create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/case.json
 create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json
 create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-c.json
 create mode 100644 management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json

diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index f21f878a4..d72ba439d 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -577,7 +577,7 @@ func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string)
 		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
 			continue
 		}
-		if !isPeerInPolicySourceGroupsFromData(nmData, peerID, policy) {
+		if !isPeerInPolicySourcesFromData(nmData, peerID, policy) {
 			continue
 		}
 		for _, checkID := range policy.SourcePostureChecks {
@@ -590,11 +590,14 @@ func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string)
 	return maps.Values(peerPostureChecks)
 }
 
-func isPeerInPolicySourceGroupsFromData(nmData *networkmap.NetworkMapData, peerID string, policy *nmdata.Policy) bool {
+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
@@ -1314,7 +1317,7 @@ func computeForwarderPortFromVersions(wtVersions []string, requiredVersion strin
 
 // 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
 	}
@@ -1334,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 {
diff --git a/management/internals/controllers/network_map/controller/posture_twin_test.go b/management/internals/controllers/network_map/controller/posture_twin_test.go
index d5c9035e0..98e0991d0 100644
--- a/management/internals/controllers/network_map/controller/posture_twin_test.go
+++ b/management/internals/controllers/network_map/controller/posture_twin_test.go
@@ -4,35 +4,65 @@ import (
 	"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"
+	"github.com/netbirdio/netbird/shared/management/types"
 )
 
-func TestPeerPostureChecksFromData_ReturnsTwinsUnchanged(t *testing.T) {
-	check := &nmdata.PostureChecks{
-		ID: "pc1",
-		Checks: nmdata.ChecksDefinition{
-			NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.30.0"},
-			OSVersionCheck: &nmdata.OSVersionCheck{Linux: &nmdata.MinKernelVersionCheck{MinKernelVersion: "6.1"}},
+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"}}},
 		},
 	}
-	nmData := &networkmap.NetworkMapData{
-		Groups: map[string]*nmdata.Group{"g1": {ID: "g1", Peers: []string{"peer1"}}},
-		Policies: []*nmdata.Policy{{
-			ID:                  "policy1",
-			Enabled:             true,
-			SourcePostureChecks: []string{"pc1"},
-			Rules:               []*nmdata.PolicyRule{{ID: "rule1", Enabled: true, Sources: []string{"g1"}}},
-		}},
-		PostureChecks: map[string]*nmdata.PostureChecks{"pc1": check},
-	}
-
-	got := peerPostureChecksFromData(nmData, "peer1")
-	require.Len(t, got, 1)
-	assert.Same(t, check, got[0])
-	assert.Len(t, got[0].GetChecks(), 2)
-
-	assert.Empty(t, peerPostureChecksFromData(nmData, "peer-outside-source-group"))
+}
+
+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/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/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/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go
index 7fef1ab35..d5868a5c1 100644
--- a/management/server/affected_peers_router_paths_test.go
+++ b/management/server/affected_peers_router_paths_test.go
@@ -146,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{
@@ -157,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)
@@ -169,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)
@@ -347,11 +346,28 @@ func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T)
 // 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)
+	s.createPostureCheckGatedPolicy(t, ctx, policyFor(s))
 
 	source, err := s.manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, s.accountID, s.sourcePeerID)
 	require.NoError(t, err)
diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go
index 9ecfaed69..7e3f02b27 100644
--- a/management/server/affected_peers_router_test.go
+++ b/management/server/affected_peers_router_test.go
@@ -173,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/peer.go b/management/server/peer.go
index 87ca57c2b..07619f51e 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -1349,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
 	}
@@ -1371,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) ([]*nmdata.PostureChecks, 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
 	}
@@ -1383,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...)
 	}
 
@@ -1395,13 +1395,17 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
 	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
diff --git a/management/server/peer_posture_test.go b/management/server/peer_posture_test.go
index 6b298f5d1..88662e2fa 100644
--- a/management/server/peer_posture_test.go
+++ b/management/server/peer_posture_test.go
@@ -9,6 +9,7 @@ import (
 
 	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"
 )
 
@@ -181,3 +182,22 @@ func TestMetaDiffAffectsPosture_NoChecks(t *testing.T) {
 	)
 	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/types/account.go b/management/server/types/account.go
index 522bb8be6..d689b0175 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -909,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)
 			}
@@ -1120,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]
@@ -1150,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]
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index eb3e4fe3b..35b5f7149 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -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/shared/management/networkmap/networkmapcompute.go b/shared/management/networkmap/networkmapcompute.go
index 1cf7aeef4..65e76d097 100644
--- a/shared/management/networkmap/networkmapcompute.go
+++ b/shared/management/networkmap/networkmapcompute.go
@@ -324,19 +324,13 @@ func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes(
 			var peerInSources, peerInDestinations bool
 
 			if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" {
-				sourcePeers = []string{rule.SourceResource.ID}
-				if rule.SourceResource.ID == peerID {
-					peerInSources = true
-				}
+				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 = []string{rule.DestinationResource.ID}
-				if rule.DestinationResource.ID == peerID {
-					peerInDestinations = true
-				}
+				destinationPeers, peerInDestinations = nmd.getPeerFromResource(rule.DestinationResource, peerID, nil, postureFailedPeers)
 			} else {
 				destinationPeers, peerInDestinations = nmd.getPeersFromGroups(rule.Destinations, peerID, nil, postureFailedPeers)
 			}
@@ -403,30 +397,16 @@ func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, so
 			filteredPeerIDs = make([]string, 0, len(group.Peers))
 			peerInGroups = false
 			for _, pid := range group.Peers {
-				peer, ok := nmd.Peers[pid]
-				if !ok || peer == nil {
+				if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) {
 					continue
 				}
 
-				if _, ok := nmd.ValidatedPeers[peer.ID]; !ok {
-					continue
-				}
-
-				isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, peer.ID)
-				if !isValid && len(pname) > 0 {
-					if _, ok := (*postureFailedPeers)[pname]; !ok {
-						(*postureFailedPeers)[pname] = make(map[string]struct{})
-					}
-					(*postureFailedPeers)[pname][peer.ID] = struct{}{}
-					continue
-				}
-
-				if peer.ID == peerID {
+				if pid == peerID {
 					peerInGroups = true
 					continue
 				}
 
-				filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+				filteredPeerIDs = append(filteredPeerIDs, pid)
 			}
 			return filteredPeerIDs, peerInGroups
 		}
@@ -436,36 +416,59 @@ func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, so
 				continue
 			}
 			seenPeerIds[pid] = struct{}{}
-			peer, ok := nmd.Peers[pid]
-			if !ok || peer == nil {
+			if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) {
 				continue
 			}
 
-			if _, ok := nmd.ValidatedPeers[peer.ID]; !ok {
-				continue
-			}
-
-			isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, peer.ID)
-			if !isValid && len(pname) > 0 {
-				if _, ok := (*postureFailedPeers)[pname]; !ok {
-					(*postureFailedPeers)[pname] = make(map[string]struct{})
-				}
-				(*postureFailedPeers)[pname][peer.ID] = struct{}{}
-				continue
-			}
-
-			if peer.ID == peerID {
+			if pid == peerID {
 				peerInGroups = true
 				continue
 			}
 
-			filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+			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 {
diff --git a/shared/management/networkmap/networkmapcompute_test.go b/shared/management/networkmap/networkmapcompute_test.go
index 8c9add8c1..ad21fd70f 100644
--- a/shared/management/networkmap/networkmapcompute_test.go
+++ b/shared/management/networkmap/networkmapcompute_test.go
@@ -448,10 +448,9 @@ func TestGetPeerNetworkMapComponents_PeerResourceRules(t *testing.T) {
 		assert.ElementsMatch(t, []string{targetID, remote.ID}, peerIDSet(c.Peers))
 	})
 
-	// Legacy parity: directly referenced peers bypass the ValidatedPeers gate
-	// and posture checks that group-derived peers go through; the client-side
-	// Calculate shares this behavior via getPeerFromResource.
-	t.Run("unvalidated source resource peer still connects", func(t *testing.T) {
+	// 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)
@@ -463,10 +462,10 @@ func TestGetPeerNetworkMapComponents_PeerResourceRules(t *testing.T) {
 
 		c := compute(nmd, targetID)
 
-		assert.ElementsMatch(t, []string{targetID, unval.ID}, peerIDSet(c.Peers))
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
 	})
 
-	t.Run("source resource peer bypasses posture checks", func(t *testing.T) {
+	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
@@ -481,10 +480,65 @@ func TestGetPeerNetworkMapComponents_PeerResourceRules(t *testing.T) {
 
 		c := compute(nmd, targetID)
 
-		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		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)
diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go
index d008ece83..e18db4ec0 100644
--- a/shared/management/types/networkmap_components.go
+++ b/shared/management/types/networkmap_components.go
@@ -230,13 +230,13 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
 			var peerInSources, peerInDestinations bool
 
 			if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" {
-				sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID)
+				sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID, policy.SourcePostureChecks)
 			} else {
 				sourcePeers, peerInSources = c.getAllPeersFromGroups(rule.Sources, targetPeerID, policy.SourcePostureChecks)
 			}
 
 			if rule.DestinationResource.Type == string(ResourceTypePeer) && rule.DestinationResource.ID != "" {
-				destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID)
+				destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID, nil)
 			} else {
 				destinationPeers, peerInDestinations = c.getAllPeersFromGroups(rule.Destinations, targetPeerID, nil)
 			}
@@ -373,8 +373,21 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nmdata.Peer) (
 }
 
 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([]*nmdata.Peer, 0, len(uniquePeerIDs))
 
 	for _, p := range uniquePeerIDs {
@@ -427,19 +440,6 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) []
 	return ids
 }
 
-func (c *NetworkMapComponents) getPeerFromResource(resource nmdata.Resource, peerID string) ([]*nmdata.Peer, bool) {
-	if resource.ID == peerID {
-		return []*nmdata.Peer{}, true
-	}
-
-	peerInfo := c.GetPeerInfo(resource.ID)
-	if peerInfo == nil {
-		return []*nmdata.Peer{}, false
-	}
-
-	return []*nmdata.Peer{peerInfo}, false
-}
-
 func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nmdata.Peer) ([]*nmdata.Peer, []*nmdata.Peer) {
 	peersToConnect := make([]*nmdata.Peer, 0, len(aclPeers))
 	var expiredPeers []*nmdata.Peer

From 11733fd718fb7889cfbf8b4e6498c0b4e74a1eb0 Mon Sep 17 00:00:00 2001
From: Bethuel Mmbaga 
Date: Fri, 28 Aug 2026 18:11:57 +0300
Subject: [PATCH 17/40] [infrastructure] Improve domain, Docker Compose, and
 license validation in self-hosted scripts (#7339)

---
 .../getting-started-enterprise.sh             | 112 ++++++++++++++++-
 infrastructure_files/getting-started.sh       |  79 +++++++++---
 infrastructure_files/migrate-to-enterprise.sh | 113 +++++++++++++++++-
 3 files changed, 279 insertions(+), 25 deletions(-)

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

From 945b0b6be210f67c70fa29906d0176c18b8de54e Mon Sep 17 00:00:00 2001
From: eYey <64911790+eYey343@users.noreply.github.com>
Date: Mon, 31 Aug 2026 10:29:05 +0200
Subject: [PATCH 18/40] Store the Android split tunnelling settings per profile
 (#7349)

Which applications the tunnel carries belongs with the rest of a
profile's preferences rather than on the Android side, so the choice
follows the profile the user is on.

Adds a split tunnel store beside the SSH session store, over a
"split-tunnel" namespace holding the mode and both selections. The two
selections are kept apart because the platform applies an allow list or
a deny list and never both, and so that switching mode does not throw
away the picks made in the other one.

Only the store itself needs the android build tag; the rest stays
untagged so it is covered by the package's host tests.
---
 client/android/split_tunnel.go       | 106 ++++++++++++++++++++++++++
 client/android/split_tunnel_store.go |  34 +++++++++
 client/android/split_tunnel_test.go  | 109 +++++++++++++++++++++++++++
 3 files changed, 249 insertions(+)
 create mode 100644 client/android/split_tunnel.go
 create mode 100644 client/android/split_tunnel_store.go
 create mode 100644 client/android/split_tunnel_test.go

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")
+	}
+}

From 086d8ba5078c9ce5b7b5e48fcf773b8e44795278 Mon Sep 17 00:00:00 2001
From: Zoltan Papp 
Date: Mon, 31 Aug 2026 10:32:36 +0200
Subject: [PATCH 19/40] [client] Close the session-expiration dialog only on
 renewal (#7337)

* [client] Close the session-expiration dialog only on an actual session renewal

The dialog auto-closed on any Connected status snapshot, but the daemon
emits Connected periodically regardless of session state, so the warning
popup disappeared on the next snapshot (~30s) with no chance to
re-authenticate. Close only when the snapshot's session deadline jumps
past the one the dialog was opened for, meaning the session was renewed
from another surface (tray action, CLI, main window).

* [client] Compare session renewals against the exact deadline in the expiration dialog

The dialog reconstructed its reference deadline from the relative seconds
URL parameter, which carries up to a second of truncation and mount
latency, forcing a renewal-detection margin wide enough to miss a renewal
made shortly after the previous login. Pass the absolute deadline (unix
ms) from both tray call sites - the extend flow's cached deadline and the
final warning's event metadata - so any forward jump in the snapshot
deadline closes the dialog; the seconds-derived fallback with a small
tolerance remains for an unknown deadline.

* [client] Derive the expiration dialog countdown from the deadline

The per-second decrement assumed the interval fires once a second, but
the webview's timers get suspended for tens of seconds under App Nap /
hidden-window throttling, leaving the displayed countdown behind the
wall clock by the suspended time. Recompute the remaining time from the
absolute deadline on every tick so the first tick after a suspension
shows the correct value.

* [client] Tolerate the warning deadline's second precision in the renewal check

The final-warning metadata formats the deadline as RFC3339 truncated to
whole seconds while the status snapshot keeps millisecond precision, so
an unchanged deadline could appear up to 999 ms newer than the exact URL
value and close the dialog on the first snapshot. Allow a sub-second
tolerance on the exact path; any real renewal jumps by at least seconds.
---
 .../session/SessionExpirationDialog.tsx       | 51 ++++++++++++++++---
 client/ui/services/windowmanager.go           |  8 ++-
 client/ui/tray_events.go                      |  3 +-
 client/ui/tray_session.go                     | 19 +++++--
 4 files changed, 66 insertions(+), 15 deletions(-)

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/services/windowmanager.go b/client/ui/services/windowmanager.go
index 4930ce22b..94dba6038 100644
--- a/client/ui/services/windowmanager.go
+++ b/client/ui/services/windowmanager.go
@@ -292,11 +292,15 @@ func (s *WindowManager) CloseBrowserLogin() {
 }
 
 // OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
-// the countdown. Singleton, destroyed on close.
-func (s *WindowManager) OpenSessionExpiration(seconds int) {
+// the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog
+// compares renewal snapshots against. Singleton, destroyed on close.
+func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds)
+	if deadlineUnixMilli > 0 {
+		startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10)
+	}
 	if s.sessionExpiration == nil {
 		opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon)
 		opts.Screen = s.getScreenBasedOnCursorPosition()
diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go
index 12da68a5c..f23b5d715 100644
--- a/client/ui/tray_events.go
+++ b/client/ui/tray_events.go
@@ -76,7 +76,8 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
 
 	if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
 		if se.Metadata[authsession.MetaFinal] == "true" {
-			t.openSessionExpiration()
+			deadline, _ := authsession.ParseExpiresAt(se.Metadata[authsession.MetaExpiresAt])
+			t.openSessionExpiration(deadline)
 			return
 		}
 		t.notifySessionWarning(
diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go
index 6e5d07740..91c38be08 100644
--- a/client/ui/tray_session.go
+++ b/client/ui/tray_session.go
@@ -284,12 +284,23 @@ func (t *Tray) dismissSessionWarning() {
 }
 
 // openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed.
-// Idempotent on the WindowManager side.
-func (t *Tray) openSessionExpiration() {
+// deadline is the absolute expiry from the warning event's metadata; when zero (older daemon,
+// malformed metadata) the cached status-snapshot deadline fills in. Idempotent on the
+// WindowManager side.
+func (t *Tray) openSessionExpiration(deadline time.Time) {
 	if t.svc.WindowManager == nil {
 		return
 	}
-	t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds)
+	if deadline.IsZero() {
+		t.sessionMu.Lock()
+		deadline = t.sessionExpiresAt
+		t.sessionMu.Unlock()
+	}
+	var deadlineMs int64
+	if !deadline.IsZero() {
+		deadlineMs = deadline.UnixMilli()
+	}
+	t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds, deadlineMs)
 }
 
 // openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time,
@@ -310,5 +321,5 @@ func (t *Tray) openSessionExtendFlow() {
 	if t.svc.WindowManager == nil {
 		return
 	}
-	t.svc.WindowManager.OpenSessionExpiration(seconds)
+	t.svc.WindowManager.OpenSessionExpiration(seconds, deadline.UnixMilli())
 }

From 7ffbcb00160dfc381972c6c318f22cbd30f2c518 Mon Sep 17 00:00:00 2001
From: Max 
Date: Mon, 31 Aug 2026 17:28:30 +0300
Subject: [PATCH 20/40] [client] Add Ukrainian localization for desktop client
 (#7035)

---
 client/ui/i18n/locales/_index.json    |    1 +
 client/ui/i18n/locales/uk/common.json | 1376 +++++++++++++++++++++++++
 2 files changed, 1377 insertions(+)
 create mode 100644 client/ui/i18n/locales/uk/common.json

diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json
index 419358d36..17fb1d8ea 100644
--- a/client/ui/i18n/locales/_index.json
+++ b/client/ui/i18n/locales/_index.json
@@ -1,6 +1,7 @@
 {
     "languages": [
         {"code": "en", "displayName": "English (US)", "englishName": "English (US)"},
+        {"code": "uk", "displayName": "Українська", "englishName": "Ukrainian"},
         {"code": "de", "displayName": "Deutsch", "englishName": "German"},
         {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"},
         {"code": "ru", "displayName": "Русский", "englishName": "Russian"},
diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json
new file mode 100644
index 000000000..4e3f24102
--- /dev/null
+++ b/client/ui/i18n/locales/uk/common.json
@@ -0,0 +1,1376 @@
+{
+    "tray.tooltip": {
+        "message": "NetBird"
+    },
+    "tray.status.disconnected": {
+        "message": "Відключено"
+    },
+    "tray.status.daemonUnavailable": {
+        "message": "Не запущено"
+    },
+    "tray.status.error": {
+        "message": "Помилка"
+    },
+    "tray.status.connected": {
+        "message": "Підключено"
+    },
+    "tray.status.connecting": {
+        "message": "Підключення"
+    },
+    "tray.status.needsLogin": {
+        "message": "Потрібно ввійти"
+    },
+    "tray.status.loginFailed": {
+        "message": "Помилка входу"
+    },
+    "tray.status.sessionExpired": {
+        "message": "Сеанс закінчився"
+    },
+    "tray.session.expiresIn": {
+        "message": "До завершення сеансу: {remaining}"
+    },
+    "tray.session.unit.lessThanMinute": {
+        "message": "менше хвилини"
+    },
+    "tray.session.unit.minute": {
+        "message": "1 хв."
+    },
+    "tray.session.unit.minutes": {
+        "message": "{count} хв."
+    },
+    "tray.session.unit.hour": {
+        "message": "1 год."
+    },
+    "tray.session.unit.hours": {
+        "message": "{count} год."
+    },
+    "tray.session.unit.day": {
+        "message": "1 дн."
+    },
+    "tray.session.unit.days": {
+        "message": "{count} дн."
+    },
+    "tray.menu.open": {
+        "message": "Відкрити NetBird"
+    },
+    "tray.menu.connect": {
+        "message": "Підключитися"
+    },
+    "tray.menu.disconnect": {
+        "message": "Відключитися"
+    },
+    "tray.menu.exitNode": {
+        "message": "Вихідний вузол"
+    },
+    "tray.menu.networks": {
+        "message": "Ресурси"
+    },
+    "tray.menu.profiles": {
+        "message": "Профілі"
+    },
+    "tray.menu.manageProfiles": {
+        "message": "Керування профілями"
+    },
+    "tray.menu.settings": {
+        "message": "Налаштування…"
+    },
+    "tray.menu.debugBundle": {
+        "message": "Створити архів діагностики"
+    },
+    "tray.menu.about": {
+        "message": "Допомога та підтримка"
+    },
+    "tray.menu.github": {
+        "message": "GitHub"
+    },
+    "tray.menu.documentation": {
+        "message": "Документація"
+    },
+    "tray.menu.troubleshoot": {
+        "message": "Діагностика"
+    },
+    "tray.menu.downloadLatest": {
+        "message": "Завантажити останню версію"
+    },
+    "tray.menu.installVersion": {
+        "message": "Встановити версію {version}"
+    },
+    "tray.menu.guiVersion": {
+        "message": "Графічний інтерфейс: {version}"
+    },
+    "tray.menu.daemonVersion": {
+        "message": "Служба: {version}"
+    },
+    "tray.menu.versionUnknown": {
+        "message": "—"
+    },
+    "tray.menu.quit": {
+        "message": "Вийти з NetBird"
+    },
+    "notify.daemonOutdated.title": {
+        "message": "Служба NetBird застаріла"
+    },
+    "notify.daemonOutdated.body": {
+        "message": "Оновіть службу NetBird, щоб користуватися застосунком."
+    },
+    "notify.update.title": {
+        "message": "Доступне оновлення NetBird"
+    },
+    "notify.update.body": {
+        "message": "Доступна версія NetBird {version}."
+    },
+    "notify.update.enforcedSuffix": {
+        "message": " Ваш адміністратор вимагає встановити це оновлення."
+    },
+    "notify.error.title": {
+        "message": "Помилка"
+    },
+    "notify.error.connect": {
+        "message": "Не вдалося підключитися"
+    },
+    "notify.error.disconnect": {
+        "message": "Не вдалося відключитися"
+    },
+    "notify.error.switchProfile": {
+        "message": "Не вдалося перемкнутися на {profile}"
+    },
+    "notify.error.exitNode": {
+        "message": "Не вдалося оновити вихідний вузол {name}"
+    },
+    "notify.sessionExpired.title": {
+        "message": "Сеанс NetBird закінчився"
+    },
+    "notify.sessionExpired.body": {
+        "message": "Ваш сеанс NetBird закінчився. Будь ласка, увійдіть знову."
+    },
+    "notify.sessionWarning.title": {
+        "message": "Сеанс невдовзі закінчиться"
+    },
+    "notify.sessionWarning.body": {
+        "message": "Ваш сеанс NetBird закінчиться через {remaining}. Натисніть «Продовжити зараз», щоб оновити його."
+    },
+    "notify.sessionWarning.bodyGeneric": {
+        "message": "Ваш сеанс NetBird невдовзі закінчиться. Натисніть «Продовжити зараз», щоб оновити його."
+    },
+    "notify.sessionWarning.extend": {
+        "message": "Продовжити зараз"
+    },
+    "notify.sessionWarning.dismiss": {
+        "message": "Закрити"
+    },
+    "notify.sessionWarning.failed": {
+        "message": "Не вдалося продовжити сеанс NetBird"
+    },
+    "notify.sessionWarning.successTitle": {
+        "message": "Сеанс NetBird продовжено"
+    },
+    "notify.sessionWarning.successBody": {
+        "message": "Ваш сеанс успішно продовжено."
+    },
+    "notify.sessionDeadlineRejected.title": {
+        "message": "Недійсний термін дії сеансу"
+    },
+    "notify.sessionDeadlineRejected.body": {
+        "message": "Сервер надіслав недійсний термін дії сеансу. Будь ласка, увійдіть знову."
+    },
+    "notify.mdm.policyApplied.title": {
+        "message": "Налаштування NetBird оновлено"
+    },
+    "notify.mdm.policyApplied.body": {
+        "message": "Конфігурацію NetBird оновлено відповідно до політики вашої організації."
+    },
+    "common.cancel": {
+        "message": "Скасувати"
+    },
+    "common.save": {
+        "message": "Зберегти"
+    },
+    "common.saveChanges": {
+        "message": "Зберегти зміни"
+    },
+    "common.saving": {
+        "message": "Збереження…"
+    },
+    "common.close": {
+        "message": "Закрити"
+    },
+    "common.copy": {
+        "message": "Копіювати"
+    },
+    "common.togglePasswordVisibility": {
+        "message": "Показати/сховати пароль"
+    },
+    "common.increase": {
+        "message": "Збільшити"
+    },
+    "common.decrease": {
+        "message": "Зменшити"
+    },
+    "common.delete": {
+        "message": "Видалити"
+    },
+    "common.create": {
+        "message": "Створити"
+    },
+    "common.add": {
+        "message": "Додати"
+    },
+    "common.remove": {
+        "message": "Вилучити"
+    },
+    "common.refresh": {
+        "message": "Оновити"
+    },
+    "common.loading": {
+        "message": "Завантаження…"
+    },
+    "common.netbird": {
+        "message": "NetBird"
+    },
+    "common.noResults.title": {
+        "message": "Результатів не знайдено"
+    },
+    "common.noResults.description": {
+        "message": "Ми не змогли нічого знайти. Спробуйте змінити пошуковий запит або налаштування фільтрів."
+    },
+    "notConnected.title": {
+        "message": "Відключено"
+    },
+    "notConnected.description": {
+        "message": "Спочатку підключіться до NetBird, щоб переглянути детальну інформацію про піри, мережеві ресурси та вихідні вузли."
+    },
+    "connect.status.disconnected": {
+        "message": "Відключено"
+    },
+    "connect.status.connecting": {
+        "message": "Підключення…"
+    },
+    "connect.status.connected": {
+        "message": "Підключено"
+    },
+    "connect.status.disconnecting": {
+        "message": "Відключення…"
+    },
+    "connect.status.daemonUnavailable": {
+        "message": "Служба недоступна"
+    },
+    "connect.status.loginRequired": {
+        "message": "Потрібно ввійти"
+    },
+    "connect.error.loginTitle": {
+        "message": "Помилка входу"
+    },
+    "connect.error.connectTitle": {
+        "message": "Помилка підключення"
+    },
+    "connect.error.disconnectTitle": {
+        "message": "Помилка відключення"
+    },
+    "nav.peers.title": {
+        "message": "Піри"
+    },
+    "nav.peers.description": {
+        "message": "Підключено {connected} з {total}"
+    },
+    "nav.resources.title": {
+        "message": "Ресурси"
+    },
+    "nav.resources.description": {
+        "message": "Активно {active} з {total}"
+    },
+    "nav.exitNode.title": {
+        "message": "Вихідні вузли"
+    },
+    "nav.exitNode.none": {
+        "message": "Неактивний"
+    },
+    "nav.exitNode.using": {
+        "message": "Через {name}"
+    },
+    "header.openSettings": {
+        "message": "Відкрити налаштування"
+    },
+    "header.togglePanel": {
+        "message": "Показати/сховати бічну панель"
+    },
+    "profile.selector.loading": {
+        "message": "Завантаження…"
+    },
+    "profile.selector.noProfile": {
+        "message": "Немає профілю"
+    },
+    "profile.selector.searchPlaceholder": {
+        "message": "Пошук профілю за назвою…"
+    },
+    "profile.selector.emptyTitle": {
+        "message": "Профілів не знайдено"
+    },
+    "profile.selector.emptyDescription": {
+        "message": "Спробуйте змінити пошуковий запит або створіть новий профіль."
+    },
+    "profile.selector.newProfile": {
+        "message": "Новий профіль"
+    },
+    "profile.selector.moreOptions": {
+        "message": "Додаткові параметри"
+    },
+    "profile.selector.deregister": {
+        "message": "Вийти з профілю"
+    },
+    "profile.selector.delete": {
+        "message": "Видалити"
+    },
+    "profile.selector.switchTo": {
+        "message": "Перемкнутися на цей профіль"
+    },
+    "profile.selector.edit": {
+        "message": "Редагувати"
+    },
+    "profile.edit.title": {
+        "message": "Редагувати профіль"
+    },
+    "profile.edit.submit": {
+        "message": "Зберегти зміни"
+    },
+    "profile.dialog.title": {
+        "message": "Введіть назву профілю"
+    },
+    "profile.dialog.nameLabel": {
+        "message": "Назва профілю"
+    },
+    "profile.dialog.description": {
+        "message": "Вкажіть зрозумілу назву для вашого профілю."
+    },
+    "profile.dialog.placeholder": {
+        "message": "наприклад, Робота"
+    },
+    "profile.dialog.submit": {
+        "message": "Додати профіль"
+    },
+    "profile.dialog.required": {
+        "message": "Будь ласка, введіть назву профілю, наприклад, «Робота» або «Дім»."
+    },
+    "profile.dialog.managementHelp": {
+        "message": "Використовуйте NetBird Cloud або власний сервер."
+    },
+    "profile.dialog.urlUnreachable": {
+        "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або додайте профіль, якщо ви впевнені, що вона правильна."
+    },
+    "header.menu.settings": {
+        "message": "Налаштування…"
+    },
+    "header.menu.defaultView": {
+        "message": "Стандартний вигляд"
+    },
+    "header.menu.advancedView": {
+        "message": "Розширений вигляд"
+    },
+    "header.menu.updateAvailable": {
+        "message": "Доступне оновлення"
+    },
+    "header.menu.open": {
+        "message": "Відкрити меню"
+    },
+    "header.profile.switch": {
+        "message": "Змінити профіль"
+    },
+    "connect.toggle.label": {
+        "message": "Перемкнути підключення NetBird"
+    },
+    "connect.localIp.label": {
+        "message": "Локальні IP-адреси"
+    },
+    "common.search": {
+        "message": "Пошук"
+    },
+    "common.filter": {
+        "message": "Фільтр"
+    },
+    "exitNodes.dropdown.trigger": {
+        "message": "Вибрати вихідний вузол"
+    },
+    "peers.row.label": {
+        "message": "Відкрити деталі для {name}, {status}"
+    },
+    "peers.dialog.title": {
+        "message": "Деталі піра"
+    },
+    "networks.row.toggle": {
+        "message": "Перемкнути {name}"
+    },
+    "networks.bulk.label": {
+        "message": "Перемкнути всі видимі ресурси"
+    },
+    "profile.switch.title": {
+        "message": "Перемкнутися на профіль «{name}»?"
+    },
+    "profile.switch.message": {
+        "message": "Ви впевнені, що хочете змінити профіль?\nВаш поточний профіль буде відключено."
+    },
+    "profile.switch.confirm": {
+        "message": "Підтвердити"
+    },
+    "profile.deregister.title": {
+        "message": "Вийти з профілю «{name}»?"
+    },
+    "profile.deregister.message": {
+        "message": "Ви впевнені, що хочете вийти з цього профілю?\nВам доведеться увійти знову, щоб використовувати його."
+    },
+    "profile.deregister.confirm": {
+        "message": "Вийти"
+    },
+    "profile.delete.title": {
+        "message": "Видалити профіль «{name}»?"
+    },
+    "profile.delete.message": {
+        "message": "Ви впевнені, що хочете видалити цей профіль?\nЦю дію неможливо скасувати."
+    },
+    "profile.delete.disabledActive": {
+        "message": "Активні профілі не можна видаляти. Перемкніться на інший профіль перед видаленням цього."
+    },
+    "profile.delete.disabledDefault": {
+        "message": "Профіль за замовчуванням не можна видалити."
+    },
+    "profile.error.switchTitle": {
+        "message": "Помилка зміни профілю"
+    },
+    "profile.error.deregisterTitle": {
+        "message": "Помилка виходу з профілю"
+    },
+    "profile.error.deleteTitle": {
+        "message": "Помилка видалення профілю"
+    },
+    "profile.error.createTitle": {
+        "message": "Помилка створення профілю"
+    },
+    "profile.error.editTitle": {
+        "message": "Помилка редагування профілю"
+    },
+    "profile.error.loadTitle": {
+        "message": "Помилка завантаження профілів"
+    },
+    "profile.dropdown.activeProfile": {
+        "message": "Активний профіль"
+    },
+    "profile.dropdown.switchProfile": {
+        "message": "Змінити профіль"
+    },
+    "profile.dropdown.noEmail": {
+        "message": "Інше"
+    },
+    "profile.dropdown.addProfile": {
+        "message": "Додати профіль"
+    },
+    "profile.dropdown.manageProfiles": {
+        "message": "Керування профілями"
+    },
+    "profile.dropdown.settings": {
+        "message": "Налаштування"
+    },
+    "settings.profiles.section.profiles": {
+        "message": "Профілі"
+    },
+    "settings.profiles.intro": {
+        "message": "Використовуйте кілька профілів NetBird одночасно, наприклад, робочий та особистий облікові записи або різні сервери керування. Додавайте профілі, виходьте з них або видаляйте їх нижче."
+    },
+    "settings.profiles.addProfile": {
+        "message": "Додати профіль"
+    },
+    "settings.profiles.active": {
+        "message": "Активний"
+    },
+    "settings.profiles.emptyTitle": {
+        "message": "Немає профілів"
+    },
+    "settings.profiles.emptyDescription": {
+        "message": "Створіть профіль, щоб підключитися до сервера керування NetBird."
+    },
+    "settings.error.loadTitle": {
+        "message": "Помилка завантаження налаштувань"
+    },
+    "settings.error.saveTitle": {
+        "message": "Помилка збереження налаштувань"
+    },
+    "settings.error.debugBundleTitle": {
+        "message": "Помилка створення архіву діагностики"
+    },
+    "settings.nav.label": {
+        "message": "Розділи налаштувань"
+    },
+    "settings.tabs.general": {
+        "message": "Загальні"
+    },
+    "settings.tabs.network": {
+        "message": "Мережа"
+    },
+    "settings.tabs.security": {
+        "message": "Безпека"
+    },
+    "settings.tabs.profiles": {
+        "message": "Профілі"
+    },
+    "settings.tabs.ssh": {
+        "message": "SSH"
+    },
+    "settings.tabs.advanced": {
+        "message": "Розширені"
+    },
+    "settings.tabs.troubleshooting": {
+        "message": "Діагностика"
+    },
+    "settings.tabs.about": {
+        "message": "Про програму"
+    },
+    "settings.tabs.updateAvailable": {
+        "message": "Доступне оновлення"
+    },
+    "settings.general.section.general": {
+        "message": "Загальні"
+    },
+    "settings.general.section.connection": {
+        "message": "Підключення"
+    },
+    "settings.general.connectOnStartup.label": {
+        "message": "Підключитися під час запуску"
+    },
+    "settings.general.connectOnStartup.help": {
+        "message": "Автоматично встановлювати підключення під час запуску служби."
+    },
+    "settings.general.notifications.label": {
+        "message": "Сповіщення на робочому столі"
+    },
+    "settings.general.notifications.help": {
+        "message": "Показувати сповіщення на робочому столі про нові оновлення та події підключення."
+    },
+    "settings.general.autostart.label": {
+        "message": "Запускати інтерфейс NetBird під час входу"
+    },
+    "settings.general.autostart.help": {
+        "message": "Автоматично запускати інтерфейс NetBird під час входу в систему. Це стосується лише графічного інтерфейсу, а не фонової служби."
+    },
+    "settings.general.autostart.errorTitle": {
+        "message": "Помилка зміни автозапуску"
+    },
+    "settings.general.keepConnectedOnQuit.label": {
+        "message": "Залишатися підключеним після виходу"
+    },
+    "settings.general.keepConnectedOnQuit.help": {
+        "message": "Підключення залишатиметься активним у фоновому режимі після закриття NetBird. Воно буде розірвано лише тоді, коли ви відключите його самостійно."
+    },
+    "settings.general.language.label": {
+        "message": "Мова інтерфейсу"
+    },
+    "settings.general.language.help": {
+        "message": "Виберіть мову для інтерфейсу NetBird."
+    },
+    "settings.general.language.search": {
+        "message": "Пошук мови…"
+    },
+    "settings.general.language.empty": {
+        "message": "Не знайдено жодної мови."
+    },
+    "settings.general.management.label": {
+        "message": "Сервер керування"
+    },
+    "settings.general.management.help": {
+        "message": "Підключайтеся до NetBird Cloud або власного сервера керування. Зміни призведуть до перепідключення клієнта."
+    },
+    "settings.general.management.cloud": {
+        "message": "Cloud"
+    },
+    "settings.general.management.selfHosted": {
+        "message": "Власний сервер"
+    },
+    "settings.general.management.urlPlaceholder": {
+        "message": "https://netbird.selfhosted.com:443"
+    },
+    "settings.general.management.urlError": {
+        "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443"
+    },
+    "settings.general.management.urlUnreachable": {
+        "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або все одно збережіть зміни, якщо ви впевнені, що вона правильна."
+    },
+    "settings.general.management.switchCloudTitle": {
+        "message": "Перемкнутися на NetBird Cloud?"
+    },
+    "settings.general.management.switchCloudMessage": {
+        "message": "Це відключить вас від власного сервера.\nВам може знадобитися увійти знову."
+    },
+    "settings.general.management.switchCloudConfirm": {
+        "message": "Перемкнутися на Cloud"
+    },
+    "settings.network.section.connectivity": {
+        "message": "Підключення"
+    },
+    "settings.network.section.routingDns": {
+        "message": "Маршрутизація та DNS"
+    },
+    "settings.network.monitor.label": {
+        "message": "Перепідключатися при зміні мережі"
+    },
+    "settings.network.monitor.help": {
+        "message": "Відстежувати мережу й автоматично перепідключатися у разі таких змін, як перемикання Wi-Fi, зміна Ethernet-підключення або вихід із режиму сну."
+    },
+    "settings.network.dns.label": {
+        "message": "Увімкнути DNS"
+    },
+    "settings.network.dns.help": {
+        "message": "Застосовувати налаштування DNS, якими керує NetBird, до локального DNS-розв’язувача хоста."
+    },
+    "settings.network.clientRoutes.label": {
+        "message": "Увімкнути клієнтські маршрути"
+    },
+    "settings.network.clientRoutes.help": {
+        "message": "Приймати маршрути від інших пірів для доступу до їхніх мереж."
+    },
+    "settings.network.serverRoutes.label": {
+        "message": "Увімкнути серверні маршрути"
+    },
+    "settings.network.serverRoutes.help": {
+        "message": "Анонсувати локальні маршрути цього хоста іншим пірам."
+    },
+    "settings.network.ipv6.label": {
+        "message": "Увімкнути IPv6"
+    },
+    "settings.network.ipv6.help": {
+        "message": "Використовувати адресацію IPv6 для оверлейної мережі NetBird."
+    },
+    "settings.security.section.firewall": {
+        "message": "Брандмауер"
+    },
+    "settings.security.section.encryption": {
+        "message": "Шифрування"
+    },
+    "settings.security.blockInbound.label": {
+        "message": "Блокувати вхідний трафік"
+    },
+    "settings.security.blockInbound.help": {
+        "message": "Відхиляти небажані підключення від пірів до цього пристрою та будь-яких мереж, які він маршрутизує. Вихідний трафік не обмежується."
+    },
+    "settings.security.blockLan.label": {
+        "message": "Блокувати доступ до LAN"
+    },
+    "settings.security.blockLan.help": {
+        "message": "Заборонити пірам отримувати доступ до вашої локальної мережі або її пристроїв, коли цей пристрій маршрутизує їхній трафік."
+    },
+    "settings.security.rosenpass.label": {
+        "message": "Увімкнути постквантову стійкість"
+    },
+    "settings.security.rosenpass.help": {
+        "message": "Додати постквантовий обмін ключами через Rosenpass поверх WireGuard®."
+    },
+    "settings.security.rosenpassPermissive.label": {
+        "message": "Увімкнути дозвільний режим"
+    },
+    "settings.security.rosenpassPermissive.help": {
+        "message": "Дозволити підключення до пірів без підтримки постквантової стійкості."
+    },
+    "settings.ssh.section.server": {
+        "message": "Сервер"
+    },
+    "settings.ssh.section.capabilities": {
+        "message": "Можливості"
+    },
+    "settings.ssh.section.authentication": {
+        "message": "Автентифікація"
+    },
+    "settings.ssh.server.label": {
+        "message": "Увімкнути SSH-сервер"
+    },
+    "settings.ssh.server.help": {
+        "message": "Запустити SSH-сервер NetBird на цьому хості, щоб інші піри могли підключатися до нього."
+    },
+    "settings.ssh.root.label": {
+        "message": "Дозволити вхід як root"
+    },
+    "settings.ssh.root.help": {
+        "message": "Дозволити пірам входити як користувач root. Вимкніть, щоб вимагати непривілейований обліковий запис."
+    },
+    "settings.ssh.sftp.label": {
+        "message": "Дозволити SFTP"
+    },
+    "settings.ssh.sftp.help": {
+        "message": "Безпечно передавати файли за допомогою нативних клієнтів SFTP або SCP."
+    },
+    "settings.ssh.localForward.label": {
+        "message": "Локальне переспрямування портів"
+    },
+    "settings.ssh.localForward.help": {
+        "message": "Дозволити пірам, що підключаються, переспрямовувати локальні порти до сервісів, доступних із цього хоста."
+    },
+    "settings.ssh.remoteForward.label": {
+        "message": "Віддалене переспрямування портів"
+    },
+    "settings.ssh.remoteForward.help": {
+        "message": "Дозволити підключеним пірам відкривати порти на цьому хості з переспрямуванням на свої машини."
+    },
+    "settings.ssh.jwt.label": {
+        "message": "Увімкнути JWT-автентифікацію"
+    },
+    "settings.ssh.jwt.help": {
+        "message": "Перевіряти кожен сеанс SSH через ваш IdP для ідентифікації користувачів та аудиту. Вимкніть, щоб покладатися лише на політики мережевих ACL, що корисно, коли IdP недоступний."
+    },
+    "settings.ssh.jwtTtl.label": {
+        "message": "Час кешування JWT (TTL)"
+    },
+    "settings.ssh.jwtTtl.help": {
+        "message": "Як довго цей клієнт кешує JWT перед повторним запитом для вихідних SSH-з’єднань. Встановіть 0, щоб вимкнути кешування та проходити автентифікацію при кожному підключенні."
+    },
+    "settings.ssh.jwtTtl.suffix": {
+        "message": "сек."
+    },
+    "settings.advanced.section.interface": {
+        "message": "Інтерфейс"
+    },
+    "settings.advanced.section.security": {
+        "message": "Безпека"
+    },
+    "settings.advanced.interfaceName.label": {
+        "message": "Назва"
+    },
+    "settings.advanced.interfaceName.error": {
+        "message": "Використовуйте 1-15 літер, цифр, крапок, дефісів або підкреслень."
+    },
+    "settings.advanced.interfaceName.errorMac": {
+        "message": "Повинно починатися з «utun», після якого має йти число (наприклад, utun100)."
+    },
+    "settings.advanced.port.label": {
+        "message": "Порт"
+    },
+    "settings.advanced.port.error": {
+        "message": "Введіть порт між {min} та {max}."
+    },
+    "settings.advanced.port.help": {
+        "message": "Якщо встановлено 0, буде використано випадковий вільний порт."
+    },
+    "settings.advanced.mtu.label": {
+        "message": "MTU"
+    },
+    "settings.advanced.mtu.error": {
+        "message": "Введіть значення MTU між {min} та {max}."
+    },
+    "settings.advanced.psk.label": {
+        "message": "Попередньо узгоджений ключ"
+    },
+    "settings.advanced.psk.help": {
+        "message": "Додатковий PSK WireGuard для симетричного шифрування. Це не те саме, що NetBird Setup Key. Ви зможете обмінюватися даними лише з тими пірами, які використовують такий самий попередньо узгоджений ключ."
+    },
+    "settings.troubleshooting.section.title": {
+        "message": "Архів діагностики"
+    },
+    "settings.troubleshooting.anonymize.label": {
+        "message": "Анонімізувати чутливу інформацію"
+    },
+    "settings.troubleshooting.anonymize.help": {
+        "message": "Приховує IP-адреси, домени та інші конфіденційні дані."
+    },
+        "settings.troubleshooting.anonymize.info": {
+        "message": "«Стандартний» залишає внутрішні адреси IPv4 та імена пірів читабельними для служби підтримки. «Суворий» додатково анонімізує приватні (RFC 1918), CGNAT- та link-local-адреси, імена пірів і публічні ключі WireGuard. Однакові значення замінюються тим самим псевдонімом, тож піри залишаються розрізнюваними. Використовуйте «Суворий», якщо ділитеся архівом за межами організації."
+    },
+    "settings.troubleshooting.anonymize.none": {
+        "message": "Вимкнено"
+    },
+    "settings.troubleshooting.anonymize.default": {
+        "message": "Стандартний"
+    },
+    "settings.troubleshooting.anonymize.strict": {
+        "message": "Суворий"
+    },
+    "settings.troubleshooting.systemInfo.label": {
+        "message": "Додати інформацію про систему"
+    },
+    "settings.troubleshooting.systemInfo.help": {
+        "message": "Додати дані про ОС, ядро, мережеві інтерфейси та таблиці маршрутизації."
+    },
+    "settings.troubleshooting.upload.label": {
+        "message": "Завантажити архів на сервери NetBird"
+    },
+    "settings.troubleshooting.upload.help": {
+        "message": "Створює ключ завантаження, який можна передати службі підтримки NetBird."
+    },
+    "settings.troubleshooting.trace.label": {
+        "message": "Увімкнути журнали рівня TRACE"
+    },
+    "settings.troubleshooting.trace.help": {
+        "message": "Підвищує рівень журналювання до TRACE на час створення архіву та відновлює його після завершення."
+    },
+    "settings.troubleshooting.capture.label": {
+        "message": "Запис сеансу"
+    },
+    "settings.troubleshooting.capture.help": {
+        "message": "Перепідключає NetBird і чекає, щоб ви могли відтворити проблему."
+    },
+    "settings.troubleshooting.packets.label": {
+        "message": "Захоплювати мережеві пакети"
+    },
+    "settings.troubleshooting.packets.help": {
+        "message": "Зберігає файл .pcap із мережевим трафіком протягом сеансу захоплення."
+    },
+    "settings.troubleshooting.duration.label": {
+        "message": "Тривалість захоплення"
+    },
+    "settings.troubleshooting.duration.help": {
+        "message": "Скільки часу триває сеанс захоплення."
+    },
+    "settings.troubleshooting.duration.suffix": {
+        "message": "хв."
+    },
+    "settings.troubleshooting.create": {
+        "message": "Створити архів"
+    },
+    "settings.troubleshooting.progress.description": {
+        "message": "Збір журналів, даних про систему та інформації про стан підключення. Зазвичай це займає хвилину. Ви можете продовжувати використовувати NetBird або закрити вікно налаштувань, поки процес триває."
+    },
+    "settings.troubleshooting.cancelling": {
+        "message": "Скасування…"
+    },
+    "settings.troubleshooting.done.uploadedTitle": {
+        "message": "Архів діагностики успішно завантажено!"
+    },
+    "settings.troubleshooting.done.savedTitle": {
+        "message": "Архів збережено"
+    },
+    "settings.troubleshooting.done.uploadedDescription": {
+        "message": "Поділіться ключем завантаження нижче зі службою підтримки NetBird. Локальну копію також збережено на вашому пристрої."
+    },
+    "settings.troubleshooting.done.savedDescription": {
+        "message": "Ваш архів діагностики збережено локально."
+    },
+    "settings.troubleshooting.done.copyKey": {
+        "message": "Копіювати ключ"
+    },
+    "settings.troubleshooting.done.openFolder": {
+        "message": "Відкрити папку"
+    },
+    "settings.troubleshooting.done.openFileLocation": {
+        "message": "Відкрити розташування файлу"
+    },
+    "settings.troubleshooting.uploadFailedWithReason": {
+        "message": "Помилка завантаження: {reason} Архів все одно збережено локально"
+    },
+    "settings.troubleshooting.uploadFailed": {
+        "message": "Помилка завантаження. Архів все одно збережено локально."
+    },
+    "settings.troubleshooting.stage.reconnecting": {
+        "message": "Перепідключення NetBird…"
+    },
+    "settings.troubleshooting.stage.capturing": {
+        "message": "Запис журналів діагностики"
+    },
+    "settings.troubleshooting.stage.bundling": {
+        "message": "Створення архіву діагностики…"
+    },
+    "settings.troubleshooting.stage.uploading": {
+        "message": "Завантаження на сервери NetBird…"
+    },
+    "settings.troubleshooting.stage.cancelling": {
+        "message": "Скасування…"
+    },
+    "settings.about.client": {
+        "message": "NetBird Client v{version}"
+    },
+    "settings.about.clientName": {
+        "message": "NetBird Client"
+    },
+    "settings.about.development": {
+        "message": "[Розробка]"
+    },
+    "settings.about.gui": {
+        "message": "Графічний інтерфейс v{version}"
+    },
+    "settings.about.guiName": {
+        "message": "Графічний інтерфейс"
+    },
+    "settings.about.copyright": {
+        "message": "© {year} NetBird. Усі права захищено."
+    },
+    "settings.about.links.imprint": {
+        "message": "Реквізити"
+    },
+    "settings.about.links.privacy": {
+        "message": "Конфіденційність"
+    },
+    "settings.about.links.cla": {
+        "message": "CLA"
+    },
+    "settings.about.links.terms": {
+        "message": "Умови використання"
+    },
+    "settings.about.community.github": {
+        "message": "GitHub"
+    },
+    "settings.about.community.slack": {
+        "message": "Slack"
+    },
+    "settings.about.community.forum": {
+        "message": "Форум"
+    },
+    "settings.about.community.documentation": {
+        "message": "Документація"
+    },
+    "settings.about.community.feedback": {
+        "message": "Зворотний зв’язок"
+    },
+    "update.banner.message": {
+        "message": "NetBird {version} готовий до встановлення."
+    },
+    "update.banner.later": {
+        "message": "Пізніше"
+    },
+    "update.banner.installNow": {
+        "message": "Встановити зараз"
+    },
+    "update.card.versionAvailableDownload": {
+        "message": "Версія {version} доступна для завантаження."
+    },
+    "update.card.versionAvailableInstall": {
+        "message": "Версія {version} доступна для встановлення."
+    },
+    "update.card.whatsNew": {
+        "message": "Що нового?"
+    },
+    "update.card.installNow": {
+        "message": "Встановити зараз"
+    },
+    "update.card.getInstaller": {
+        "message": "Завантажити"
+    },
+    "update.card.autoCheckInterval": {
+        "message": "NetBird перевіряє наявність оновлень у фоновому режимі."
+    },
+    "update.card.changelog": {
+        "message": "Список змін"
+    },
+    "update.card.onLatestVersion": {
+        "message": "Ви використовуєте останню версію"
+    },
+    "update.header.tooltip": {
+        "message": "Доступне оновлення"
+    },
+    "update.overlay.updatingVersion": {
+        "message": "Оновлення NetBird до v{version}"
+    },
+    "update.overlay.updating": {
+        "message": "Оновлення NetBird"
+    },
+    "update.overlay.description": {
+        "message": "Доступна новіша версія, яка зараз встановлюється. NetBird автоматично перезапуститься після завершення оновлення."
+    },
+    "update.overlay.error.timeoutTitle": {
+        "message": "Оновлення триває занадто довго"
+    },
+    "update.overlay.error.timeoutDescription": {
+        "message": "Встановлення {target} тривало занадто довго і не завершилося."
+    },
+    "update.overlay.error.canceledTitle": {
+        "message": "Оновлення зупинено"
+    },
+    "update.overlay.error.canceledDescription": {
+        "message": "Оновлення до {target} було скасовано до його завершення."
+    },
+    "update.overlay.error.failTitle": {
+        "message": "Не вдалося встановити оновлення"
+    },
+    "update.overlay.error.failDescription": {
+        "message": "Не вдалося встановити оновлення до {target}."
+    },
+    "update.overlay.error.unknownMessage": {
+        "message": "Невідома помилка"
+    },
+    "update.overlay.error.targetVersion": {
+        "message": "v{version}"
+    },
+    "update.overlay.error.targetFallback": {
+        "message": "нової версії"
+    },
+    "update.error.loadStateTitle": {
+        "message": "Помилка завантаження стану оновлення"
+    },
+    "update.error.triggerTitle": {
+        "message": "Помилка запуску оновлення"
+    },
+    "update.page.versionLine": {
+        "message": "Оновлення клієнта до версії {version}."
+    },
+    "update.page.versionLineGeneric": {
+        "message": "Оновлення клієнта."
+    },
+    "update.page.outdated": {
+        "message": "Ваша версія клієнта старіша за версію для автооновлення, задану в Management."
+    },
+    "update.page.status.running": {
+        "message": "Оновлення"
+    },
+    "update.page.status.timeout": {
+        "message": "Час очікування оновлення минув. Будь ласка, спробуйте ще раз."
+    },
+    "update.page.status.canceled": {
+        "message": "Оновлення скасовано."
+    },
+    "update.page.status.failed": {
+        "message": "Помилка оновлення: {message}"
+    },
+    "update.page.status.unknownError": {
+        "message": "невідома помилка оновлення"
+    },
+    "update.page.failedTitle": {
+        "message": "Помилка оновлення"
+    },
+    "update.page.timeoutMessage": {
+        "message": "Час очікування оновлення минув."
+    },
+    "update.page.dontClose": {
+        "message": "Будь ласка, не закривайте це вікно."
+    },
+    "update.page.updating": {
+        "message": "Оновлення…"
+    },
+    "update.page.complete": {
+        "message": "Оновлення завершено"
+    },
+    "update.page.failed": {
+        "message": "Помилка оновлення"
+    },
+    "window.title.settings": {
+        "message": "Налаштування"
+    },
+    "window.title.signIn": {
+        "message": "Вхід"
+    },
+    "window.title.sessionExpiration": {
+        "message": "Термін дії сеансу закінчується"
+    },
+    "window.title.updating": {
+        "message": "Оновлення"
+    },
+    "window.title.welcome": {
+        "message": "Ласкаво просимо до NetBird"
+    },
+    "window.title.error": {
+        "message": "Помилка"
+    },
+    "welcome.title": {
+        "message": "Знайдіть NetBird в області сповіщень"
+    },
+    "welcome.titleMac": {
+        "message": "Знайдіть NetBird у рядку меню"
+    },
+    "welcome.description": {
+        "message": "NetBird працює в області сповіщень. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування."
+    },
+    "welcome.descriptionMac": {
+        "message": "NetBird працює в рядку меню. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування."
+    },
+    "welcome.continue": {
+        "message": "Продовжити"
+    },
+    "welcome.back": {
+        "message": "Назад"
+    },
+    "welcome.management.title": {
+        "message": "Налаштування NetBird"
+    },
+    "welcome.management.description": {
+        "message": "Натисніть «Продовжити», щоб розпочати, або виберіть Власний сервер, якщо у вас є власний сервер NetBird."
+    },
+    "welcome.management.cloud.title": {
+        "message": "NetBird Cloud"
+    },
+    "welcome.management.cloud.description": {
+        "message": "Використовуйте наш хмарний сервіс. Налаштування не потрібне."
+    },
+    "welcome.management.selfHosted.title": {
+        "message": "Власний сервер"
+    },
+    "welcome.management.selfHosted.description": {
+        "message": "Підключіться до власного сервера керування."
+    },
+    "welcome.management.urlLabel": {
+        "message": "URL-адреса сервера керування"
+    },
+    "welcome.management.urlPlaceholder": {
+        "message": "https://netbird.selfhosted.com:443"
+    },
+    "welcome.management.urlInvalid": {
+        "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443"
+    },
+    "welcome.management.urlUnreachable": {
+        "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або вашу мережу, а потім продовжуйте, якщо ви впевнені, що вона правильна."
+    },
+    "welcome.management.checking": {
+        "message": "Перевірка…"
+    },
+    "browserLogin.title": {
+        "message": "Завершіть вхід у браузері"
+    },
+    "browserLogin.notSeeing": {
+        "message": "Ми відкрили вкладку браузера, щоб ви могли завершити вхід. Не бачите її?"
+    },
+    "browserLogin.tryAgain": {
+        "message": "Спробувати ще раз"
+    },
+    "browserLogin.openFailedTitle": {
+        "message": "Помилка відкриття браузера"
+    },
+    "sessionExpiration.title": {
+        "message": "Термін дії сеансу невдовзі закінчиться"
+    },
+    "sessionExpiration.titleLater": {
+        "message": "Термін дії вашого сеансу закінчиться"
+    },
+    "sessionExpiration.description": {
+        "message": "Цей пристрій невдовзі буде відключено. Поновіть сеанс, увійшовши через браузер."
+    },
+    "sessionExpiration.descriptionLater": {
+        "message": "Вхід через браузер підтримує підключення цього пристрою до вашої мережі."
+    },
+    "sessionExpiration.stay": {
+        "message": "Продовжити сеанс"
+    },
+    "sessionExpiration.authenticate": {
+        "message": "Увійти"
+    },
+    "sessionExpiration.logout": {
+        "message": "Вийти"
+    },
+    "sessionExpiration.expired": {
+        "message": "Термін дії сеансу закінчився"
+    },
+    "sessionExpiration.expiredDescription": {
+        "message": "Пристрій відключено. Пройдіть автентифікацію у браузері, щоб перепідключитися."
+    },
+    "sessionExpiration.close": {
+        "message": "Закрити"
+    },
+    "sessionExpiration.extendFailedTitle": {
+        "message": "Помилка продовження сеансу"
+    },
+    "sessionExpiration.logoutFailedTitle": {
+        "message": "Помилка виходу"
+    },
+    "peers.search.placeholder": {
+        "message": "Пошук за ім’ям або IP"
+    },
+    "peers.filter.all": {
+        "message": "Усі"
+    },
+    "peers.filter.online": {
+        "message": "Онлайн"
+    },
+    "peers.filter.offline": {
+        "message": "Офлайн"
+    },
+    "peers.empty.title": {
+        "message": "Немає доступних пірів"
+    },
+    "peers.empty.description": {
+        "message": "У вас немає доступних пірів або доступу до жодного з них."
+    },
+    "peers.details.domain": {
+        "message": "Домен"
+    },
+    "peers.details.netbirdIp": {
+        "message": "NetBird IP"
+    },
+    "peers.details.netbirdIpv6": {
+        "message": "NetBird IPv6"
+    },
+    "peers.details.publicKey": {
+        "message": "Публічний ключ"
+    },
+    "peers.details.connection": {
+        "message": "Підключення"
+    },
+    "peers.details.latency": {
+        "message": "Затримка"
+    },
+    "peers.details.lastHandshake": {
+        "message": "Останнє рукостискання"
+    },
+    "peers.details.statusSince": {
+        "message": "Останнє оновлення підключення"
+    },
+    "peers.details.bytes": {
+        "message": "Байти"
+    },
+    "peers.details.bytesSent": {
+        "message": "Надіслано"
+    },
+    "peers.details.bytesReceived": {
+        "message": "Отримано"
+    },
+    "peers.details.localIce": {
+        "message": "Локальний ICE"
+    },
+    "peers.details.remoteIce": {
+        "message": "Віддалений ICE"
+    },
+    "peers.details.never": {
+        "message": "Ніколи"
+    },
+    "peers.details.justNow": {
+        "message": "Щойно"
+    },
+    "peers.details.refresh": {
+        "message": "Оновити"
+    },
+    "peers.status.connected": {
+        "message": "Підключено"
+    },
+    "peers.status.connecting": {
+        "message": "Підключення"
+    },
+    "peers.status.disconnected": {
+        "message": "Відключено"
+    },
+    "peers.details.relayAddress": {
+        "message": "Ретранслятор"
+    },
+    "peers.details.networks": {
+        "message": "Ресурси"
+    },
+    "peers.details.relayed": {
+        "message": "Через ретранслятор"
+    },
+    "peers.details.p2p": {
+        "message": "P2P"
+    },
+    "peers.details.rosenpass": {
+        "message": "Rosenpass увімкнено"
+    },
+    "networks.search.placeholder": {
+        "message": "Пошук за мережею або доменом"
+    },
+    "networks.filter.all": {
+        "message": "Усі"
+    },
+    "networks.filter.active": {
+        "message": "Активні"
+    },
+    "networks.filter.overlapping": {
+        "message": "Перетинаються"
+    },
+    "networks.empty.title": {
+        "message": "Немає доступних ресурсів"
+    },
+    "networks.empty.description": {
+        "message": "У вас немає доступних мережевих ресурсів або доступу до жодного з них."
+    },
+    "networks.selected": {
+        "message": "Вибрано"
+    },
+    "networks.unselected": {
+        "message": "Не вибрано"
+    },
+    "networks.ips.heading": {
+        "message": "Визначені IP-адреси"
+    },
+    "networks.bulk.selectionCount": {
+        "message": "Активні: {selected} з {total}"
+    },
+    "networks.bulk.enableAll": {
+        "message": "Увімкнути всі"
+    },
+    "networks.bulk.disableAll": {
+        "message": "Вимкнути всі"
+    },
+    "exitNodes.search.placeholder": {
+        "message": "Пошук вихідних вузлів"
+    },
+    "exitNodes.none": {
+        "message": "Немає"
+    },
+    "exitNodes.empty.title": {
+        "message": "Немає доступних вихідних вузлів"
+    },
+    "exitNodes.empty.description": {
+        "message": "Цьому піру не надано жодного вихідного вузла."
+    },
+    "exitNodes.card.title": {
+        "message": "Вихідний вузол"
+    },
+    "exitNodes.card.statusActive": {
+        "message": "Активний"
+    },
+    "exitNodes.card.statusInactive": {
+        "message": "Неактивний"
+    },
+    "exitNodes.dropdown.noneTitle": {
+        "message": "Немає"
+    },
+    "exitNodes.dropdown.noneDescription": {
+        "message": "Пряме підключення без вихідного вузла"
+    },
+    "quickActions.connect": {
+        "message": "Підключитися"
+    },
+    "quickActions.disconnect": {
+        "message": "Відключитися"
+    },
+    "daemon.unavailable.title": {
+        "message": "Служба NetBird не запущена"
+    },
+    "daemon.unavailable.description": {
+        "message": "Програма перепідключиться автоматично, щойно служба запрацює."
+    },
+    "daemon.unavailable.docsLink": {
+        "message": "Документація"
+    },
+    "daemon.outdated.title": {
+        "message": "Клієнт NetBird застарів"
+    },
+    "daemon.outdated.description": {
+        "message": "Новий графічний інтерфейс несумісний зі старою версією клієнта NetBird. Оновіть клієнт, щоб використовувати нову програму."
+    },
+    "daemon.outdated.download": {
+        "message": "Завантажити останню версію"
+    },
+    "error.jwt_clock_skew": {
+        "message": "Помилка входу: годинник цього пристрою не синхронізовано із сервером. Будь ласка, синхронізуйте системний годинник і спробуйте знову."
+    },
+    "error.jwt_expired": {
+        "message": "Термін дії вашого токена входу закінчився. Будь ласка, увійдіть знову."
+    },
+    "error.jwt_signature_invalid": {
+        "message": "Помилка входу: недійсний підпис токена. Будь ласка, зверніться до адміністратора."
+    },
+    "error.session_expired": {
+        "message": "Термін дії вашого сеансу закінчився. Будь ласка, увійдіть знову."
+    },
+    "error.invalid_setup_key": {
+        "message": "Setup Key відсутній або недійсний."
+    },
+    "error.permission_denied": {
+        "message": "Вхід відхилено сервером."
+    },
+    "error.daemon_unreachable": {
+        "message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба."
+    },
+    "error.unknown": {
+        "message": "Помилка операції."
+    },
+    "error.elevation_unavailable": {
+        "message": "NetBird не зміг запросити в системи привілеї, необхідні для внесення змін. Замість цього виконайте:"
+    },
+    "error.elevation_failed": {
+        "message": "Не вдалося застосувати зміни з підвищеними привілеями. Замість цього виконайте:"
+    },
+    "settings.ssh.privilege.actorRoot": {
+        "message": "прав root"
+    },
+    "settings.ssh.privilege.actorAdministrator": {
+        "message": "прав адміністратора"
+    },
+    "settings.ssh.privilege.hint": {
+        "message": "Потребує {actor}. Замість цього виконайте:"
+    },
+    "settings.ssh.privilege.oneWay": {
+        "message": "Ви можете вимкнути це, але щоб увімкнути знову, знадобиться {actor}:"
+    },
+    "settings.ssh.privilege.oneWayInverted": {
+        "message": "Ви можете увімкнути це, але щоб вимкнути знову, знадобиться {actor}:"
+    },
+        "settings.ssh.privilege.authorizePending": {
+        "message": "Очікування авторизації…"
+    }
+}

From 24959e1ed9f7484b1d7813d44001a9b6f073d054 Mon Sep 17 00:00:00 2001
From: Laotree 
Date: Mon, 31 Aug 2026 23:49:59 +0800
Subject: [PATCH 21/40] [client] Drop agentConnecting whenever ICE session
 state clears (#7327)

* [client] Drop agentConnecting whenever ICE session state clears

Closing a WorkerICE raced a blocked dial goroutine: Close released the
agent while connect() was still inside Dial, and the goroutine's own
cleanup skipped its flag reset because w.agent no longer matched. With
agentConnecting stuck on true, evalConnStatus read the peer as
connected, the reconnection guard stopped sending offers and
same-session offers were dropped, so the peer could not recover without
a restart. An aborted recreate in OnNewOffer reaches the same wedged
state without any race.

Route every teardown path through one abandonNegotiation helper so the
agent and flag fields always clear together; Close now also cleans up
residual state left by an aborted recreate.

* [client] Drive the ICE teardown race test through the real dial goroutine

The regression test simulated the stale goroutine by calling closeAgent
directly, so it pinned the symptom rather than the mechanism. Rework it
to start a real negotiation, tear it down mid-flight and let the actual
goroutine run its own cleanup: with no remote responder the dial can
only fail once Close cancels it, so the interleaving stays deterministic
without sleeps or injection points.

Assert the full idle state that abandonNegotiation owns (agent nil,
connecting false, remote session ID empty) instead of only InProgress,
and make the stale-cleanup ownership test verify that the newer session
survives field by field.

* [client] Assert live remote session ID after stale ICE cleanup

The stale-cleanup test compared a snapshot captured before closeAgent
ran, so clearing the field during cleanup would have gone unnoticed.
Read the field under the mutex after the cleanup instead.

* [client] Give the ICE race tests a no-op signal client

The candidate callback fires from a real gather and dereferences the
signaler, so a nil one crashes the test package intermittently when
gather wins the race against Close. Build the worker with a stub
signal.Client instead.

* [client] Read the ICE dial cancel func from an argument in connect

The error paths read w.agentDialerCancel without holding muxAgent while
OnNewOffer rewrites the field for a newer negotiation, a data race the
new teardown test trips under -race. Reading a stale value also let an
old goroutine cancel another session's dial. Capture the cancel func at
goroutine spawn, like the dial context already is.

* [client] Guard the ICE dial success path against stale negotiations

The stale-cleanup guard in closeAgent only protected teardown. Its
success-path counterpart was missing: an older negotiation could complete
agentDial after a newer one replaced w.agent, then clear the newer
session's agentConnecting, record lastSuccess and publish its dead
connection via onICEConnectionIsReady.

Verify ownership under muxAgent twice: right after the dial returns, so a
stale goroutine drops its connection before touching a closed agent, and
again at the state-commit point, atomic with the agentConnecting and
lastSuccess writes, so a replacement arriving in the meantime cannot get
its state clobbered. Both paths close the stale connection and return
without modifying worker state. A regression test holds session A's dial
open until session B is installed, then releases it; the stale connection
must be discarded and B's agent, connecting flag and remote session ID
must survive.

* [client] Fix ICE teardown test leak and document the stale delivery window

A code review of the stale-negotiation guard found a leftover resource
leak in TestWorkerICE_StaleCloseAgentKeepsCurrentSession: session B is
never closed, so its ICE sockets and blocked dial goroutine live as long
as the test process. Register t.Cleanup(w.Close).

The delivery race flagged after the success-path guard is pre-existing
and self-correcting - the newer negotiation overwrites the transient
endpoint - so document it in the existing todo instead of locking the
callback, which would invert lock order against Conn.Close. Adjust the
teardown test comment to match the now-synchronous Close flag clearing.
---
 client/internal/peer/worker_ice.go            |  96 +++++--
 client/internal/peer/worker_ice_close_test.go | 257 ++++++++++++++++++
 2 files changed, 332 insertions(+), 21 deletions(-)
 create mode 100644 client/internal/peer/worker_ice_close_test.go

diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go
index 83cac13f5..d17f6e693 100644
--- a/client/internal/peer/worker_ice.go
+++ b/client/internal/peer/worker_ice.go
@@ -64,6 +64,9 @@ type WorkerICE struct {
 
 	// portForwardAttempted tracks if we've already tried port forwarding this session
 	portForwardAttempted bool
+
+	// dialFunc, when non-nil, replaces agentDial in connect(). Only for tests.
+	dialFunc func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error)
 }
 
 func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) {
@@ -123,7 +126,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
 			w.log.Errorf("failed to create new session ID: %s", err)
 		}
 		w.sessionID = sessionID
-		w.agent = nil
+		w.abandonNegotiation()
 	}
 
 	var preferredCandidateTypes []ice.CandidateType
@@ -151,7 +154,9 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
 		w.remoteSessionID = ""
 	}
 
-	go w.connect(dialerCtx, agent, remoteOfferAnswer)
+	// Capture the cancel func at spawn time: connect reads it from the argument
+	// instead of the field, which a newer OnNewOffer may already have replaced.
+	go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer)
 }
 
 // OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer.
@@ -200,16 +205,16 @@ func (w *WorkerICE) Close() {
 	w.muxAgent.Lock()
 	defer w.muxAgent.Unlock()
 
-	if w.agent == nil {
-		return
+	if w.agent != nil {
+		w.agentDialerCancel()
+		if err := w.agent.Close(); err != nil {
+			w.log.Warnf("failed to close ICE agent: %s", err)
+		}
 	}
-
-	w.agentDialerCancel()
-	if err := w.agent.Close(); err != nil {
-		w.log.Warnf("failed to close ICE agent: %s", err)
-	}
-
-	w.agent = nil
+	// Unconditional: a dial goroutine racing this Close skips its own cleanup
+	// (closeAgent finds a nil agent), so the flags must be dropped here too or
+	// the reconnection guard reads the stale state as Connected forever.
+	w.abandonNegotiation()
 }
 
 func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) {
@@ -247,31 +252,52 @@ func (w *WorkerICE) SessionID() ICESessionID {
 // will block until connection succeeded
 // but it won't release if ICE Agent went into Disconnected or Failed state,
 // so we have to cancel it with the provided context once agent detected a broken connection
-func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) {
+func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) {
 	w.log.Debugf("gather candidates")
 	if err := agent.GatherCandidates(); err != nil {
 		w.log.Warnf("failed to gather candidates: %s", err)
-		w.closeAgent(agent, w.agentDialerCancel)
+		w.closeAgent(agent, dialerCancel)
 		return
 	}
 
 	w.log.Debugf("agent dial")
-	remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer)
+	dial := func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) {
+		return w.agentDial(ctx, agent, remoteOfferAnswer)
+	}
+	if w.dialFunc != nil {
+		dial = w.dialFunc
+	}
+	remoteConn, err := dial(ctx, agent, remoteOfferAnswer)
 	if err != nil {
 		w.log.Debugf("failed to dial the remote peer: %s", err)
-		w.closeAgent(agent, w.agentDialerCancel)
+		w.closeAgent(agent, dialerCancel)
 		return
 	}
 	w.log.Debugf("agent dial succeeded")
 
+	// A newer negotiation may have replaced our agent while agentDial was
+	// blocked. Drop the dead connection before running pair retrieval, port
+	// punching or candidate work against a closed agent. The commit-point
+	// check below still guards a replacement arriving after this point.
+	w.muxAgent.Lock()
+	stale := w.agent != agent
+	w.muxAgent.Unlock()
+	if stale {
+		if err := remoteConn.Close(); err != nil {
+			w.log.Warnf("failed to close stale ICE connection: %s", err)
+		}
+		w.log.Warnf("discarding connection from a stale ICE negotiation")
+		return
+	}
+
 	pair, err := agent.GetSelectedCandidatePair()
 	if err != nil {
-		w.closeAgent(agent, w.agentDialerCancel)
+		w.closeAgent(agent, dialerCancel)
 		return
 	}
 	if pair == nil {
 		w.log.Warnf("selected candidate pair is nil, cannot proceed")
-		w.closeAgent(agent, w.agentDialerCancel)
+		w.closeAgent(agent, dialerCancel)
 		return
 	}
 
@@ -301,11 +327,27 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent
 
 	w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString())
 	w.muxAgent.Lock()
+	// Authoritative ownership guard: a negotiation that lost w.agent to a newer
+	// one between the post-dial check and the commit must not clear agentConnecting,
+	// record lastSuccess or report the connection, so the state commit has to be
+	// atomic with the check.
+	if w.agent != agent {
+		w.muxAgent.Unlock()
+		if err := remoteConn.Close(); err != nil {
+			w.log.Warnf("failed to close stale ICE connection: %s", err)
+		}
+		w.log.Warnf("discarding connection from a stale ICE negotiation")
+		return
+	}
 	w.agentConnecting = false
 	w.lastSuccess = time.Now()
 	w.muxAgent.Unlock()
 
 	// todo: the potential problem is a race between the onConnectionStateChange
+	// and the delivery below: after this unlock, a newer offer can replace
+	// w.agent before onICEConnectionIsReady runs, delivering this (now stale)
+	// connection. The newer negotiation overwrites it with its own delivery,
+	// so the window only ever downgrades an endpoint transiently.
 	w.conn.onICEConnectionIsReady(selectedPriority(pair), ci)
 }
 
@@ -321,20 +363,32 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C
 	sessionChanged := w.remoteSessionChanged
 	w.remoteSessionChanged = false
 
+	// Only the owner of the current session may reset its state: a stale dial
+	// goroutine waking after a newer attempt must not clobber it.
 	if w.agent == agent {
-		// consider to remove from here and move to the OnNewOffer
 		sessionID, err := NewICESessionID()
 		if err != nil {
 			w.log.Errorf("failed to create new session ID: %s", err)
 		}
 		w.sessionID = sessionID
-		w.agent = nil
-		w.agentConnecting = false
-		w.remoteSessionID = ""
+		w.abandonNegotiation()
 	}
 	return sessionChanged
 }
 
+// abandonNegotiation drops all recorded ICE session state so the worker treats the
+// next offer as a fresh start instead of a duplicate of a dead negotiation. The
+// agent and agentConnecting flags must change together: leaving one stale wedges
+// the reconnection guard into reporting Connected forever. It neither cancels an
+// in-flight dial nor closes an agent — callers dispose of those themselves first,
+// so a stale goroutine can never cancel another session's dial through this path.
+// Caller must hold muxAgent.
+func (w *WorkerICE) abandonNegotiation() {
+	w.agent = nil
+	w.agentConnecting = false
+	w.remoteSessionID = ""
+}
+
 func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
 	// wait local endpoint configuration
 	time.Sleep(time.Second)
diff --git a/client/internal/peer/worker_ice_close_test.go b/client/internal/peer/worker_ice_close_test.go
new file mode 100644
index 000000000..834a4dd6d
--- /dev/null
+++ b/client/internal/peer/worker_ice_close_test.go
@@ -0,0 +1,257 @@
+package peer
+
+import (
+	"context"
+	"net"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+	icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
+	signal "github.com/netbirdio/netbird/shared/signal/client"
+	sProto "github.com/netbirdio/netbird/shared/signal/proto"
+)
+
+// stubSignalClient satisfies signal.Client as a no-op so the candidate
+// goroutine spawned by a real GatherCandidates never dereferences a nil
+// signaler in tests.
+type stubSignalClient struct{}
+
+func (stubSignalClient) Close() error                                               { return nil }
+func (stubSignalClient) StreamConnected() bool                                      { return false }
+func (stubSignalClient) GetStatus() signal.Status                                   { return signal.StreamDisconnected }
+func (stubSignalClient) Receive(context.Context, func(*sProto.Message) error) error { return nil }
+func (stubSignalClient) Ready() bool                                                { return false }
+func (stubSignalClient) IsHealthy() bool                                            { return false }
+func (stubSignalClient) WaitStreamConnected(context.Context)                        {}
+func (stubSignalClient) SendToStream(*sProto.EncryptedMessage) error                { return nil }
+func (stubSignalClient) Send(*sProto.Message) error                                 { return nil }
+func (stubSignalClient) SetOnReconnectedListener(func())                            {}
+
+// newTestWorkerICE builds a worker with real pion plumbing and no-op signaling.
+func newTestWorkerICE(t *testing.T) *WorkerICE {
+	t.Helper()
+
+	config := connConf
+	stunTurn := &icemaker.StunTurn{}
+	stunTurn.Store(nil)
+	config.ICEConfig.StunTurn = stunTurn
+
+	w, err := NewWorkerICE(context.Background(), log.WithField("test", t.Name()), config, nil,
+		NewSignaler(stubSignalClient{}, wgtypes.Key{}), nil, nil, false)
+	require.NoError(t, err, "worker setup must succeed")
+	return w
+}
+
+// TestWorkerICE_CloseDuringDial_ClearsConnectingFlag drives the teardown race
+// through the real dial goroutine instead of simulating its cleanup.
+//
+// The real-world sequence this models:
+//  1. OnNewOffer starts a negotiation: agent set, agentConnecting = true,
+//     go connect()
+//  2. The network dies and connect() stays blocked inside GatherCandidates/Dial
+//  3. A WG handshake timeout calls Close(): the agent is released and the dial
+//     context cancelled, but agentConnecting is not reset
+//  4. The real goroutine wakes with an error and runs its own cleanup
+//     (closeAgent), where `w.agent == agent` is now false, so the flag reset
+//     is skipped
+//
+// There is no remote responder, so Dial can never succeed: whatever point the
+// goroutine is at, closing first forces it down the error path. Before the fix
+// the flag stays true forever and the deadline below expires.
+func TestWorkerICE_CloseDuringDial_ClearsConnectingFlag(t *testing.T) {
+	w := newTestWorkerICE(t)
+
+	sid := ICESessionID("test-session-id")
+	w.OnNewOffer(&OfferAnswer{
+		IceCredentials: IceCredentials{
+			UFrag: "testufrag",
+			Pwd:   "testpwdtestpwdtestpwd12",
+		},
+		SessionID: &sid,
+	})
+	require.True(t, w.InProgress(), "OnNewOffer must mark the negotiation as in progress")
+
+	// Teardown wins the race while connect() is still running.
+	w.Close()
+
+	// Close drops the flags synchronously, so the assertion below does not
+	// converge on the goroutine: the deadline only absorbs the dial goroutine
+	// waking up in the background, proving nothing re-wedges it afterwards.
+	require.Eventually(t, func() bool {
+		return !w.InProgress()
+	}, 10*time.Second, 50*time.Millisecond,
+		"Close must leave the negotiation idle even while the dial goroutine is still winding down")
+
+	// abandonNegotiation owns these three fields together; the worker is idle
+	// only when all of them are dropped.
+	w.muxAgent.Lock()
+	defer w.muxAgent.Unlock()
+	assert.Nil(t, w.agent, "no agent may survive the teardown")
+	assert.False(t, w.agentConnecting, "the connecting flag must match the nil agent")
+	assert.Empty(t, w.remoteSessionID, "a dead session's remote ID must not linger")
+}
+
+// TestWorkerICE_CloseClearsResidualConnectingState covers Close on a worker whose
+// agent is already gone but whose flag is stuck on true, e.g. after an aborted
+// recreate in OnNewOffer or after a first Close raced a dial goroutine.
+func TestWorkerICE_CloseClearsResidualConnectingState(t *testing.T) {
+	w := newTestWorkerICE(t)
+
+	w.muxAgent.Lock()
+	w.agentConnecting = true
+	w.muxAgent.Unlock()
+
+	w.Close()
+
+	assert.False(t, w.InProgress(), "Close must drop residual connecting state even without a live agent")
+
+	w.muxAgent.Lock()
+	defer w.muxAgent.Unlock()
+	assert.Nil(t, w.agent)
+	assert.False(t, w.agentConnecting)
+	assert.Empty(t, w.remoteSessionID)
+}
+
+// TestWorkerICE_StaleCloseAgentKeepsCurrentSession pins the ownership guard in
+// closeAgent: a late-waking dial goroutine from an older session must not reset
+// the state of a newer negotiation that reused the worker. The newer session
+// must survive wholesale - agent, flag and remote session identity alike.
+func TestWorkerICE_StaleCloseAgentKeepsCurrentSession(t *testing.T) {
+	w := newTestWorkerICE(t)
+	t.Cleanup(w.Close)
+
+	sidA := ICESessionID("session-a")
+	w.OnNewOffer(&OfferAnswer{
+		IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"},
+		SessionID:      &sidA,
+	})
+	w.muxAgent.Lock()
+	oldAgent := w.agent
+	oldCancel := w.agentDialerCancel
+	w.muxAgent.Unlock()
+	require.NotNil(t, oldAgent, "OnNewOffer must have created an ICE agent")
+
+	w.Close()
+
+	sidB := ICESessionID("session-b")
+	w.OnNewOffer(&OfferAnswer{
+		IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"},
+		SessionID:      &sidB,
+	})
+	require.True(t, w.InProgress(), "the second negotiation must be in flight")
+
+	w.muxAgent.Lock()
+	newAgent := w.agent
+	w.muxAgent.Unlock()
+
+	// The old dial goroutine finally wakes and cleans up its captured agent.
+	w.closeAgent(oldAgent, oldCancel)
+
+	w.muxAgent.Lock()
+	defer w.muxAgent.Unlock()
+	assert.Same(t, newAgent, w.agent, "the current agent must be untouched by the stale cleanup")
+	assert.True(t, w.agentConnecting, "the current negotiation must stay in flight")
+	// Read live under the lock: a snapshot captured before the stale cleanup
+	// would pass even if the cleanup wiped current state.
+	assert.Equal(t, sidB, w.remoteSessionID, "the remote session identity must be preserved")
+}
+
+// closeTrackConn records Close calls so a test can assert that a discarded
+// connection was actually released.
+type closeTrackConn struct {
+	net.Conn
+	closed atomic.Bool
+}
+
+func (c *closeTrackConn) Close() error {
+	c.closed.Store(true)
+	return c.Conn.Close()
+}
+
+// TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation pins the ownership guard
+// in connect()'s success path: a dial that came back after a newer negotiation
+// replaced the agent must discard its connection and leave the newer session's
+// state - agent, agentConnecting, remoteSessionID, lastSuccess - intact.
+//
+// The dial hook holds session A's goroutine open until session B is installed,
+// then returns a live connection, mimicking the vendored pion dial which hands
+// out a live *ice.Conn when a pair is selected without checking afterwards
+// whether the agent was replaced meanwhile. Releasing A's dial therefore
+// exercises the stale-success commit path deterministically instead of racing
+// real ICE.
+func TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation(t *testing.T) {
+	w := newTestWorkerICE(t)
+	t.Cleanup(w.Close)
+
+	dialStarted := make(chan struct{})
+	releaseDial := make(chan struct{})
+	staleConn := &closeTrackConn{}
+
+	var calls atomic.Int32
+	w.dialFunc = func(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) {
+		if calls.Add(1) == 1 {
+			// Session A: hold the goroutine open until session B is installed,
+			// then return a live connection, mimicking the vendored pion dial
+			// which hands out a live *ice.Conn once a pair is selected without
+			// re-checking whether the agent was replaced meanwhile. Releasing
+			// the dial therefore exercises the stale-success commit path
+			// deterministically instead of racing real ICE.
+			close(dialStarted)
+			<-releaseDial
+			client, _ := net.Pipe()
+			staleConn.Conn = client
+			return staleConn, nil
+		}
+		// A newer negotiation parks on its dialer context, cancelled by the
+		// t.Cleanup Close at test end.
+		<-ctx.Done()
+		return nil, ctx.Err()
+	}
+
+	sidA := ICESessionID("session-a")
+	w.OnNewOffer(&OfferAnswer{
+		IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"},
+		SessionID:      &sidA,
+	})
+	require.True(t, w.InProgress(), "session A must be in flight")
+
+	// Session A's goroutine is now parked in the dial hook.
+	<-dialStarted
+
+	sidB := ICESessionID("session-b")
+	w.OnNewOffer(&OfferAnswer{
+		IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"},
+		SessionID:      &sidB,
+	})
+
+	w.muxAgent.Lock()
+	agentB := w.agent
+	w.lastSuccess = time.Time{}
+	w.muxAgent.Unlock()
+	require.NotNil(t, agentB, "session B must have created an ICE agent")
+	require.True(t, w.InProgress(), "session B must be in flight")
+
+	// Release session A's dial: it must be recognized as stale and discarded.
+	close(releaseDial)
+	require.Eventually(t, func() bool {
+		return staleConn.closed.Load()
+	}, 10*time.Second, 10*time.Millisecond,
+		"the stale connection must be closed by the ownership guard")
+
+	w.muxAgent.Lock()
+	defer w.muxAgent.Unlock()
+	assert.Same(t, agentB, w.agent, "session A must not uninstall session B's agent")
+	assert.True(t, w.agentConnecting, "session A must not clear session B's connecting flag")
+	assert.Equal(t, sidB, w.remoteSessionID, "session A must not clear session B's remote session identity")
+	assert.True(t, w.lastSuccess.IsZero(), "session A must not record a success for session B")
+	// The commit block guards agentConnecting, lastSuccess and
+	// onICEConnectionIsReady together, so the state assertions above imply the
+	// callback never ran for session A; the nil conn would have panicked the
+	// stale goroutine on any invocation.
+}

From 12e8874517f1d33f807915dbff9017e8e080c725 Mon Sep 17 00:00:00 2001
From: Theodor Midtlien 
Date: Mon, 31 Aug 2026 18:01:14 +0200
Subject: [PATCH 22/40] [client, relay, management] Bump go version to 1.26 and
 go-quic to v0.62.0 (#7359)

* Bump go version to 1.26 and go-quic to v0.62.0
* Replace deprecated ecdsa public key assembly and add tests for jwt
* Update goversioninfo
* Pin go toolchain to 1.26.7
---
 .devcontainer/Dockerfile                  |   2 +-
 .github/workflows/golang-test-linux.yml   |   2 +-
 .github/workflows/release.yml             |   4 +-
 CONTRIBUTING.md                           |   4 +-
 client/testutil/privileged/runner_test.go |   2 +-
 client/ui/build/docker/Dockerfile.cross   |   2 +-
 client/ui/build/docker/Dockerfile.server  |   2 +-
 combined/Dockerfile.multistage            |   2 +-
 docs/testing-privileged.md                |   2 +-
 e2e/harness/Dockerfile.client             |   2 +-
 go.mod                                    |  14 +-
 go.sum                                    |  16 +-
 management/Dockerfile.multistage          |   2 +-
 proxy/Dockerfile                          |   2 +-
 proxy/Dockerfile.multistage               |   2 +-
 shared/auth/jwt/validator.go              |  66 +++++--
 shared/auth/jwt/validator_test.go         | 214 ++++++++++++++++++++++
 17 files changed, 294 insertions(+), 46 deletions(-)
 create mode 100644 shared/auth/jwt/validator_test.go

diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 0661e0c71..9b6a0edfd 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25-bookworm
+FROM golang:1.26.7-bookworm
 
 RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
     && apt-get -y install --no-install-recommends\
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index c93e36e4e..f24dfbe9d 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -233,7 +233,7 @@ jobs:
             -e GOCACHE=${CONTAINER_GOCACHE} \
             -e GOMODCACHE=${CONTAINER_GOMODCACHE} \
             -e CONTAINER=${CONTAINER} \
-            golang:1.25-alpine \
+            golang:1.26.7-alpine \
             sh -c ' \
               apk update; apk add --no-cache \
                 ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4d1945451..c1bbe9c44 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -215,7 +215,7 @@ jobs:
           echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
 
       - name: Install goversioninfo
-        run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
+        run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0
       - name: Generate windows syso amd64
         run: goversioninfo  -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
       - name: Generate windows syso arm64
@@ -435,7 +435,7 @@ jobs:
           tar -xf llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64.tar.xz
           echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
       - name: Install goversioninfo
-        run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
+        run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0
       - name: Install wails3 CLI
         # Version derived from go.mod so the binding generator always matches
         # the wails runtime the binary links against.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9dea37ec8..aef749cfa 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -192,7 +192,7 @@ dependencies are installed. Here is a short guide on how that can be done.
 
 ### Requirements
 
-#### Go 1.25
+#### Go 1.26
 
 Follow the installation guide from https://go.dev/
 
@@ -200,7 +200,7 @@ Follow the installation guide from https://go.dev/
 
 The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need:
 
-- Go ≥ 1.25
+- Go ≥ 1.26
 - Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
 - The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
 - The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest`
diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go
index d1945894d..157005d3e 100644
--- a/client/testutil/privileged/runner_test.go
+++ b/client/testutil/privileged/runner_test.go
@@ -25,7 +25,7 @@ import (
 // (.github/workflows/golang-test-linux.yml, test_client_on_docker).
 const (
 	containerImage = "golang"
-	containerTag   = "1.25-alpine"
+	containerTag   = "1.26.7-alpine"
 )
 
 const (
diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross
index a487b8db0..55c0d69e1 100644
--- a/client/ui/build/docker/Dockerfile.cross
+++ b/client/ui/build/docker/Dockerfile.cross
@@ -13,7 +13,7 @@
 #   docker run --rm -v $(pwd):/app wails-cross windows amd64
 #   docker run --rm -v $(pwd):/app wails-cross windows arm64
 
-FROM golang:1.25-bookworm
+FROM golang:1.26.7-bookworm
 
 ARG TARGETARCH
 
diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server
index 58fb64f76..57183f1d2 100644
--- a/client/ui/build/docker/Dockerfile.server
+++ b/client/ui/build/docker/Dockerfile.server
@@ -2,7 +2,7 @@
 # Multi-stage build for minimal image size
 
 # Build stage
-FROM golang:alpine AS builder
+FROM golang:1.26.7-alpine AS builder
 
 WORKDIR /app
 
diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage
index 79746819d..011379c2f 100644
--- a/combined/Dockerfile.multistage
+++ b/combined/Dockerfile.multistage
@@ -1,4 +1,4 @@
-FROM golang:1.25-bookworm AS builder
+FROM golang:1.26.7-bookworm AS builder
 WORKDIR /app
 
 # Install build dependencies
diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md
index cf2f23171..72e8a0f8f 100644
--- a/docs/testing-privileged.md
+++ b/docs/testing-privileged.md
@@ -32,7 +32,7 @@ list; both are optional and default to the full privileged suite.
 
 1. Skips immediately when it detects it is already inside the container
    (`DOCKER_CI=true`), so the privileged tests run in place instead of recursing.
-2. Otherwise spins up a `golang:1.25-alpine` container (matching CI),
+2. Otherwise spins up a `golang:1.26.7-alpine` container (matching CI),
    bind-mounts the repo and the host Go build/module caches, installs the
    required packages, and runs `go test -tags 'devcert privileged'` over the
    client packages.
diff --git a/e2e/harness/Dockerfile.client b/e2e/harness/Dockerfile.client
index 74a3ec245..4c76b95c6 100644
--- a/e2e/harness/Dockerfile.client
+++ b/e2e/harness/Dockerfile.client
@@ -3,7 +3,7 @@
 # artifact), so this mirrors its alpine runtime + entrypoint while compiling the
 # CGO-free client inline. BuildKit cache mounts keep rebuilds incremental.
 
-FROM golang:1.25-bookworm AS builder
+FROM golang:1.26.7-bookworm AS builder
 WORKDIR /src
 COPY go.mod go.sum ./
 RUN --mount=type=cache,target=/go/pkg/mod go mod download
diff --git a/go.mod b/go.mod
index cede9c22d..a2fe1e55b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,10 @@
 module github.com/netbirdio/netbird
 
-go 1.25.5
+go 1.26.0
 
-toolchain go1.25.12
+// Pin the toolchain to a patch release >= go1.26.2
+// See https://go.dev/issue/77875.
+toolchain go1.26.7
 
 require (
 	cunicu.li/go-rosenpass v0.5.42
@@ -101,13 +103,13 @@ require (
 	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/quic-go/quic-go v0.62.0
 	github.com/redis/go-redis/v9 v9.7.3
 	github.com/rs/xid v1.3.0
 	github.com/shirou/gopsutil/v4 v4.25.8
 	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
 	github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
-	github.com/stretchr/testify v1.11.1
+	github.com/stretchr/testify v1.12.1
 	github.com/testcontainers/testcontainers-go v0.37.0
 	github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0
 	github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0
@@ -289,7 +291,6 @@ require (
 	github.com/pion/transport/v2 v2.2.4 // indirect
 	github.com/pion/turn/v4 v4.1.1 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
-	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/pquerna/otp v1.5.0 // indirect
 	github.com/prometheus/common v0.67.5 // indirect
@@ -299,7 +300,7 @@ require (
 	github.com/ryanuber/go-glob v1.0.0 // indirect
 	github.com/shopspring/decimal v1.4.0 // indirect
 	github.com/spf13/cast v1.10.0 // indirect
-	github.com/stretchr/objx v0.5.2 // indirect
+	github.com/stretchr/objx v0.5.3 // indirect
 	github.com/tinylib/msgp v1.6.3 // indirect
 	github.com/tklauser/go-sysconf v0.3.15 // indirect
 	github.com/tklauser/numcpus v0.10.0 // indirect
@@ -315,6 +316,7 @@ require (
 	go.opentelemetry.io/otel/trace v1.43.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.3 // indirect
+	go.yaml.in/yaml/v3 v3.0.5 // indirect
 	golang.org/x/text v0.41.0 // indirect
 	golang.org/x/tools v0.49.0 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
diff --git a/go.sum b/go.sum
index e5bf6248d..3e0b4f5dc 100644
--- a/go.sum
+++ b/go.sum
@@ -582,8 +582,10 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
 github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
 github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
-github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
-github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
+github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
+github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8=
+github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
 github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -619,8 +621,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
@@ -630,8 +632,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
 github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
 github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
 github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI=
@@ -717,6 +719,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
 go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
 go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
 goauthentik.io/api/v3 v3.2023051.3 h1:NebAhD/TeTWNo/9X3/Uj+rM5fG1HaiLOlKTNLQv9Qq4=
 goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy4+7G8u4U=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
diff --git a/management/Dockerfile.multistage b/management/Dockerfile.multistage
index 619f84615..5d037f1b1 100644
--- a/management/Dockerfile.multistage
+++ b/management/Dockerfile.multistage
@@ -1,4 +1,4 @@
-FROM golang:1.25-bookworm AS builder
+FROM golang:1.26.7-bookworm AS builder
 WORKDIR /app
 
 # Install build dependencies
diff --git a/proxy/Dockerfile b/proxy/Dockerfile
index 22c4cbfaa..5944d944e 100644
--- a/proxy/Dockerfile
+++ b/proxy/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25-alpine AS builder
+FROM golang:1.26.7-alpine AS builder
 WORKDIR /app
 
 RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \
diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage
index 4f360a811..d1db32296 100644
--- a/proxy/Dockerfile.multistage
+++ b/proxy/Dockerfile.multistage
@@ -1,4 +1,4 @@
-FROM golang:1.25-alpine AS builder
+FROM golang:1.26.7-alpine AS builder
 WORKDIR /app
 
 COPY go.mod go.sum ./
diff --git a/shared/auth/jwt/validator.go b/shared/auth/jwt/validator.go
index cf18b2cf6..62e127751 100644
--- a/shared/auth/jwt/validator.go
+++ b/shared/auth/jwt/validator.go
@@ -289,36 +289,64 @@ func getPublicKey(token *jwt.Token, jwks *Jwks) (interface{}, error) {
 	return nil, errKeyNotFound
 }
 
-func getPublicKeyFromECDSA(jwk JSONWebKey) (publicKey *ecdsa.PublicKey, err error) {
+func curveFromName(crv string) (elliptic.Curve, error) {
+	switch crv {
+	case p256:
+		return elliptic.P256(), nil
+	case p384:
+		return elliptic.P384(), nil
+	case p521:
+		return elliptic.P521(), nil
+	default:
+		return nil, fmt.Errorf("unsupported elliptic curve %q", crv)
+	}
+}
+
+func getPublicKeyFromECDSA(jwk JSONWebKey) (*ecdsa.PublicKey, error) {
 	if jwk.X == "" || jwk.Y == "" || jwk.Crv == "" {
 		return nil, fmt.Errorf("ecdsa key incomplete")
 	}
 
-	var xCoordinate []byte
-	if xCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.X); err != nil {
+	curve, err := curveFromName(jwk.Crv)
+	if err != nil {
 		return nil, err
 	}
 
-	var yCoordinate []byte
-	if yCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.Y); err != nil {
-		return nil, err
+	xCoordinate, err := base64.RawURLEncoding.DecodeString(jwk.X)
+	if err != nil {
+		return nil, fmt.Errorf("decode ecdsa x coordinate: %w", err)
 	}
 
-	publicKey = &ecdsa.PublicKey{}
-
-	var curve elliptic.Curve
-	switch jwk.Crv {
-	case p256:
-		curve = elliptic.P256()
-	case p384:
-		curve = elliptic.P384()
-	case p521:
-		curve = elliptic.P521()
+	yCoordinate, err := base64.RawURLEncoding.DecodeString(jwk.Y)
+	if err != nil {
+		return nil, fmt.Errorf("decode ecdsa y coordinate: %w", err)
 	}
 
-	publicKey.Curve = curve
-	publicKey.X = big.NewInt(0).SetBytes(xCoordinate)
-	publicKey.Y = big.NewInt(0).SetBytes(yCoordinate)
+	var x, y big.Int
+	x.SetBytes(xCoordinate)
+	y.SetBytes(yCoordinate)
+
+	bits := curve.Params().BitSize
+	if x.BitLen() > bits {
+		return nil, fmt.Errorf("ecdsa x coordinate is %d bits, exceeds curve %s field size of %d bits", x.BitLen(), jwk.Crv, bits)
+	}
+	if y.BitLen() > bits {
+		return nil, fmt.Errorf("ecdsa y coordinate is %d bits, exceeds curve %s field size of %d bits", y.BitLen(), jwk.Crv, bits)
+	}
+
+	// Round up: P-521's field is 521 bits, so a coordinate needs 66 bytes, not 65.
+	size := (bits + 7) / 8
+
+	// Assemble the SEC 1 uncompressed point (0x04 || X || Y)
+	point := make([]byte, 1+2*size)
+	point[0] = 4
+	x.FillBytes(point[1 : 1+size])
+	y.FillBytes(point[1+size:])
+
+	publicKey, err := ecdsa.ParseUncompressedPublicKey(curve, point)
+	if err != nil {
+		return nil, fmt.Errorf("parse ecdsa public key: %w", err)
+	}
 
 	return publicKey, nil
 }
diff --git a/shared/auth/jwt/validator_test.go b/shared/auth/jwt/validator_test.go
new file mode 100644
index 000000000..a5b3f4a39
--- /dev/null
+++ b/shared/auth/jwt/validator_test.go
@@ -0,0 +1,214 @@
+package jwt
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ecdsaJWK builds a JWK for pub using uncompressed-point encoding
+func ecdsaJWK(t *testing.T, kid string, pub *ecdsa.PublicKey, crv string, size int) JSONWebKey {
+	t.Helper()
+
+	point, err := pub.Bytes()
+	require.NoError(t, err)
+	require.Len(t, point, 1+2*size)
+	require.Equal(t, byte(4), point[0], "expected uncompressed point")
+
+	return JSONWebKey{
+		Kty: "EC",
+		Kid: kid,
+		Use: "sig",
+		Crv: crv,
+		X:   base64.RawURLEncoding.EncodeToString(point[1 : 1+size]),
+		Y:   base64.RawURLEncoding.EncodeToString(point[1+size:]),
+	}
+}
+
+func TestGetPublicKeyFromECDSA_RoundTrip(t *testing.T) {
+	tests := []struct {
+		crv   string
+		curve elliptic.Curve
+		size  int
+	}{
+		{p256, elliptic.P256(), 32},
+		{p384, elliptic.P384(), 48},
+		{p521, elliptic.P521(), 66},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.crv, func(t *testing.T) {
+			priv, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
+			require.NoError(t, err)
+
+			got, err := getPublicKeyFromECDSA(ecdsaJWK(t, "kid", &priv.PublicKey, tc.crv, tc.size))
+			require.NoError(t, err)
+			assert.True(t, priv.PublicKey.Equal(got), "parsed key differs from the original")
+		})
+	}
+}
+
+// TestGetPublicKeyFromECDSA_ShortCoordinate covers IdPs that strip leading zero
+// bytes from a coordinate instead of padding to the curve's field size.
+func TestGetPublicKeyFromECDSA_ShortCoordinate(t *testing.T) {
+	var (
+		priv  *ecdsa.PrivateKey
+		point []byte
+	)
+	for i := 0; i < 10000; i++ {
+		key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+		require.NoError(t, err)
+
+		p, err := key.PublicKey.Bytes()
+		require.NoError(t, err)
+
+		if p[1] == 0 || p[33] == 0 {
+			priv, point = key, p
+			break
+		}
+	}
+	require.NotNil(t, priv, "no key with a leading zero coordinate byte was generated")
+
+	jwk := JSONWebKey{
+		Kty: "EC",
+		Kid: "kid",
+		Crv: p256,
+		X:   base64.RawURLEncoding.EncodeToString(bytes.TrimLeft(point[1:33], "\x00")),
+		Y:   base64.RawURLEncoding.EncodeToString(bytes.TrimLeft(point[33:], "\x00")),
+	}
+
+	got, err := getPublicKeyFromECDSA(jwk)
+	require.NoError(t, err)
+	assert.True(t, priv.PublicKey.Equal(got))
+}
+
+func TestGetPublicKeyFromECDSA_Invalid(t *testing.T) {
+	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	require.NoError(t, err)
+	valid := ecdsaJWK(t, "kid", &priv.PublicKey, p256, 32)
+
+	offCurve := valid
+	x, err := base64.RawURLEncoding.DecodeString(valid.X)
+	require.NoError(t, err)
+	x[31] ^= 0xff
+	offCurve.X = base64.RawURLEncoding.EncodeToString(x)
+
+	// 33 non-zero bytes is 264 bits, past P-256's 256-bit field.
+	oversized := valid
+	oversized.X = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 33))
+
+	// P-521 coordinates occupy 66 bytes but only 521 bits, so a full 66-byte
+	// 0xff value (528 bits) is over the field size without being over the byte
+	// length. Only a bit-length bound catches this.
+	overP521 := JSONWebKey{
+		Kty: "EC",
+		Crv: p521,
+		X:   base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 66)),
+		Y:   base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 66)),
+	}
+
+	zeroPoint := valid
+	zeroPoint.X = base64.RawURLEncoding.EncodeToString(make([]byte, 32))
+	zeroPoint.Y = base64.RawURLEncoding.EncodeToString(make([]byte, 32))
+
+	tests := []struct {
+		name        string
+		jwk         JSONWebKey
+		errContains string
+	}{
+		{name: "missing crv", jwk: JSONWebKey{Kty: "EC", X: valid.X, Y: valid.Y}},
+		{name: "missing x", jwk: JSONWebKey{Kty: "EC", Crv: p256, Y: valid.Y}},
+		{name: "unsupported curve", jwk: JSONWebKey{Kty: "EC", Crv: "P-224", X: valid.X, Y: valid.Y}, errContains: "unsupported elliptic curve"},
+		{name: "undecodable x", jwk: JSONWebKey{Kty: "EC", Crv: p256, X: "!!not base64!!!", Y: valid.Y}, errContains: "decode ecdsa x coordinate"},
+		{name: "coordinate over field size", jwk: oversized, errContains: "exceeds curve P-256 field size of 256 bits"},
+		{name: "p521 coordinate over field size", jwk: overP521, errContains: "exceeds curve P-521 field size of 521 bits"},
+		{name: "off-curve point", jwk: offCurve, errContains: "parse ecdsa public key"},
+		{name: "point at infinity", jwk: zeroPoint, errContains: "parse ecdsa public key"},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			key, err := getPublicKeyFromECDSA(tc.jwk)
+			require.Error(t, err)
+			assert.Nil(t, key)
+			if tc.errContains != "" {
+				assert.ErrorContains(t, err, tc.errContains)
+			}
+		})
+	}
+}
+
+// TestValidateAndParse_ECDSA verifies an ES256-signed token end to end, proving
+// the parsed key actually validates signatures.
+func TestValidateAndParse_ECDSA(t *testing.T) {
+	const (
+		kid      = "es256-kid"
+		issuer   = "https://issuer.example.com/"
+		audience = "netbird"
+	)
+
+	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	require.NoError(t, err)
+
+	jwks, err := json.Marshal(Jwks{Keys: []JSONWebKey{ecdsaJWK(t, kid, &priv.PublicKey, p256, 32)}})
+	require.NoError(t, err)
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write(jwks)
+	}))
+	defer srv.Close()
+
+	token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
+		"iss": issuer,
+		"aud": audience,
+		"sub": "user-1",
+		"iat": time.Now().Add(-time.Minute).Unix(),
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+	token.Header["kid"] = kid
+
+	signed, err := token.SignedString(priv)
+	require.NoError(t, err)
+
+	v := NewValidator(issuer, []string{audience}, srv.URL, false)
+
+	parsed, err := v.ValidateAndParse(context.Background(), signed)
+	require.NoError(t, err)
+	require.True(t, parsed.Valid)
+
+	claims, ok := parsed.Claims.(jwt.MapClaims)
+	require.True(t, ok)
+	assert.Equal(t, "user-1", claims["sub"])
+
+	// A token signed by a different key of the same curve must be rejected.
+	other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	require.NoError(t, err)
+
+	forged := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
+		"iss": issuer,
+		"aud": audience,
+		"sub": "user-1",
+		"iat": time.Now().Add(-time.Minute).Unix(),
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+	forged.Header["kid"] = kid
+
+	forgedSigned, err := forged.SignedString(other)
+	require.NoError(t, err)
+
+	_, err = v.ValidateAndParse(context.Background(), forgedSigned)
+	require.Error(t, err)
+}

From 930a25319db16db588fa77cb50a7dde99e6d4369 Mon Sep 17 00:00:00 2001
From: Maxim Egorov 
Date: Mon, 31 Aug 2026 18:47:37 +0200
Subject: [PATCH 23/40] [client] Keep the route selection on an invalid request
 and apply it on a partial one (#7292)

* [client] Keep the route selection when every requested ID is unavailable

A non-append SelectRoutes() wipes the current selection before applying
the requested one, but it validated the requested IDs only afterwards,
while already mutating. A request naming no available route at all left
every route deselected and returned an error - so a typo in a route ID
silently dropped the user's exit node, and the routes stayed applied
while the selector claimed nothing was selected.

Validate first and bail out before touching any state when nothing in
the request is available. A request with at least one available route
keeps applying the valid part and reporting the rest, and an empty
request still deselects everything, since that is the caller asking for
exactly that rather than a failed lookup.

* [client] Trim the new comments to the contributing guide's length budget

CONTRIBUTING.md caps comments at 90 characters per line and roughly 250
per comment. The three comments added by this PR were over both limits.
The test comments also restated their own test names, so they lose that
half and keep only the why.

* [client] Apply the route selection even when some IDs are unknown

SelectRoutes and DeselectRoutes returned the error before TriggerSelection,
so a request mixing valid and unknown network IDs changed the selector but
never reached the routing table. The valid routes read as selected while
`ip route` showed nothing.

Trigger the selection first and return the error afterwards. The inner
selectRoutes already applied the valid part of a partial request, only the
outer layer dropped it.

* [client] Publish the network selection event on a partial failure

Returning early on error was correct while an error meant nothing had
happened. A partial failure now changes the selection and the routing
table, so returning first left the change with no trace in the event log
or the UI, even though the new state had already been broadcast.

* [client] Cover the append and deselect-all paths of the selection guard

The append path was never destructive and behaves the same with or without
the early return, so that case is characterization rather than a regression
test. The deselect-all case is a real guard: the early return also skips
resetting deselectAll, so a typo no longer drops the "nothing selected,
including future networks" policy.

* [client] Pin that a fully invalid selection disturbs nothing

The selection is now applied on every request, including one where no ID is
known and the selector is left untouched. Nothing may be torn down or
reinstalled on that path.

* Revert "[client] Publish the network selection event on a partial failure"

This reverts commit 26219592.

The event would lie on the opposite path: when no requested ID is available
the selector is left untouched, so an unconditional publish reports a change
that never happened. Telling that case from a partial failure needs the
manager to report whether anything was applied, which is a new signal in its
API and does not belong in a PR about the selector guard. Follow-up instead.

---------

Co-authored-by: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
---
 client/internal/routemanager/selection.go     | 25 ++++---
 .../internal/routemanager/selection_test.go   | 74 +++++++++++++++++++
 .../internal/routeselector/routeselector.go   | 27 +++++--
 .../routeselector/routeselector_test.go       | 67 +++++++++++++++++
 4 files changed, 176 insertions(+), 17 deletions(-)

diff --git a/client/internal/routemanager/selection.go b/client/internal/routemanager/selection.go
index 6d5feec79..b81d51b67 100644
--- a/client/internal/routemanager/selection.go
+++ b/client/internal/routemanager/selection.go
@@ -17,23 +17,30 @@ import (
 // are mutually exclusive: if the selection activates an exit node, every other
 // available exit node is deselected so two can't be active at once. With
 // appendRoute=false the previous selection is replaced instead of extended.
+// A partial failure (e.g. an unknown ID mixed with valid ones) still applies
+// the valid IDs to the routing table; the unknown ones are reported in the
+// returned error.
 func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error {
-	if err := m.selectRoutes(ids, appendRoute); err != nil {
-		return err
-	}
+	err := m.selectRoutes(ids, appendRoute)
+	// Apply regardless of err: selectRoutes already selects the valid part of a
+	// partial request, and skipping this on error would leave those routes
+	// selected in the selector but never installed in the routing table.
 	m.TriggerSelection(m.GetClientRoutes())
-	return nil
+	return err
 }
 
 // DeselectRoutes removes the routes with the given network IDs from the
 // selection and applies the change. V4/v6 exit-node pairs are expanded
-// automatically.
+// automatically. A partial failure (e.g. an unknown ID mixed with valid ones)
+// still applies the valid IDs to the routing table; the unknown ones are
+// reported in the returned error.
 func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error {
-	if err := m.deselectRoutes(ids); err != nil {
-		return err
-	}
+	err := m.deselectRoutes(ids)
+	// Apply regardless of err: deselectRoutes already deselects the valid part
+	// of a partial request, and skipping this on error would leave those routes
+	// installed in the routing table despite being marked deselected.
 	m.TriggerSelection(m.GetClientRoutes())
-	return nil
+	return err
 }
 
 func (m *DefaultManager) deselectRoutes(ids []route.NetID) error {
diff --git a/client/internal/routemanager/selection_test.go b/client/internal/routemanager/selection_test.go
index 6066b5661..4ef9ddb88 100644
--- a/client/internal/routemanager/selection_test.go
+++ b/client/internal/routemanager/selection_test.go
@@ -1,12 +1,17 @@
 package routemanager
 
 import (
+	"context"
 	"net/netip"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"golang.org/x/exp/maps"
 
+	"github.com/netbirdio/netbird/client/internal/peer"
+	"github.com/netbirdio/netbird/client/internal/routemanager/client"
+	"github.com/netbirdio/netbird/client/internal/routemanager/notifier"
 	"github.com/netbirdio/netbird/client/internal/routeselector"
 	"github.com/netbirdio/netbird/route"
 )
@@ -112,6 +117,75 @@ func TestSelectRoutes_UnknownRoute(t *testing.T) {
 	assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail")
 }
 
+// newPartialFailureTestManager exercises the real install/remove path without
+// touching the system: the noop refcounter absorbs the route changes, and every
+// route already has a watcher, so none is started.
+func newPartialFailureTestManager() *DefaultManager {
+	ctx := context.Background()
+
+	m := &DefaultManager{
+		ctx: ctx,
+		clientRoutes: route.HAMap{
+			"lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p1"}},
+			"other|10.1.2.0/24":  {{NetID: "other", Network: netip.MustParsePrefix("10.1.2.0/24"), Peer: "p2"}},
+		},
+		routeSelector:  routeselector.NewRouteSelector(),
+		notifier:       notifier.NewNotifier(),
+		statusRecorder: peer.NewRecorder("https://mgm"),
+		activeRoutes:   make(map[route.HAUniqueID]client.RouteHandler),
+		clientNetworks: map[route.HAUniqueID]*client.Watcher{
+			"lan|192.168.1.0/24": client.NewWatcher(client.WatcherConfig{Context: ctx}),
+			"other|10.1.2.0/24":  client.NewWatcher(client.WatcherConfig{Context: ctx}),
+		},
+	}
+	m.setupRefCounters(true)
+	return m
+}
+
+// Regression for the reported symptom: a partial failure returned before
+// TriggerSelection ran, so the valid route was marked selected while never
+// reaching the routing table (activeRoutes/ip route).
+func TestSelectRoutes_PartialFailureStillInstallsValidRoute(t *testing.T) {
+	m := newPartialFailureTestManager()
+
+	err := m.SelectRoutes([]route.NetID{"missing", "lan"}, false)
+
+	assert.Error(t, err, "the unknown id must still be reported")
+	assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the valid route must be installed despite the error")
+	assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must not be installed")
+}
+
+// Mirror of the case above: a partial failure must remove the valid route from
+// the routing table, not just mark it deselected in the selector.
+func TestDeselectRoutes_PartialFailureStillRemovesValidRoute(t *testing.T) {
+	m := newPartialFailureTestManager()
+
+	require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false))
+	require.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"))
+	require.Contains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"))
+
+	err := m.DeselectRoutes([]route.NetID{"missing", "other"})
+
+	assert.Error(t, err, "the unknown id must still be reported")
+	assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must be removed")
+	assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the untouched route stays installed")
+}
+
+// The selection now runs on every request, including one where no ID is known
+// and the selector stays untouched. Nothing may be torn down or reinstalled on
+// that path.
+func TestSelectRoutes_TotalFailureLeavesInstalledRoutesAlone(t *testing.T) {
+	m := newPartialFailureTestManager()
+
+	require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false))
+	installed := maps.Keys(m.activeRoutes)
+
+	err := m.SelectRoutes([]route.NetID{"missing"}, false)
+
+	assert.Error(t, err, "the unknown id must still be reported")
+	assert.ElementsMatch(t, installed, maps.Keys(m.activeRoutes), "a fully invalid request must not disturb the routing table")
+}
+
 func TestExitNodeSelectionHelpers(t *testing.T) {
 	routesMap := map[route.NetID][]*route.Route{
 		"exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index 1254b384d..8a64ad316 100644
--- a/client/internal/routeselector/routeselector.go
+++ b/client/internal/routeselector/routeselector.go
@@ -32,6 +32,22 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al
 	rs.mu.Lock()
 	defer rs.mu.Unlock()
 
+	// Validate before mutating: a non-append selection wipes the current selection
+	// first, so a request of only unavailable routes would deselect everything and
+	// put nothing back. An empty request means deselect all, so it still goes through.
+	var err *multierror.Error
+	available := make([]route.NetID, 0, len(routes))
+	for _, r := range routes {
+		if !slices.Contains(allRoutes, r) {
+			err = multierror.Append(err, fmt.Errorf("route '%s' is not available", r))
+			continue
+		}
+		available = append(available, r)
+	}
+	if len(available) == 0 && err != nil {
+		return errors.FormatErrorOrNil(err)
+	}
+
 	if !appendRoute || rs.deselectAll {
 		if rs.deselectedRoutes == nil {
 			rs.deselectedRoutes = map[route.NetID]struct{}{}
@@ -46,14 +62,9 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al
 		}
 	}
 
-	var err *multierror.Error
-	for _, route := range routes {
-		if !slices.Contains(allRoutes, route) {
-			err = multierror.Append(err, fmt.Errorf("route '%s' is not available", route))
-			continue
-		}
-		delete(rs.deselectedRoutes, route)
-		rs.selectedRoutes[route] = struct{}{}
+	for _, r := range available {
+		delete(rs.deselectedRoutes, r)
+		rs.selectedRoutes[r] = struct{}{}
 	}
 
 	rs.deselectAll = false
diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go
index 2b1ba3fb9..f26d022e9 100644
--- a/client/internal/routeselector/routeselector_test.go
+++ b/client/internal/routeselector/routeselector_test.go
@@ -887,3 +887,70 @@ func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) {
 	assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected")
 	assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected")
 }
+
+// A non-append selection clears the current selection before applying the requested
+// one, so an all-unavailable request used to leave nothing selected while returning
+// an error. Requests with at least one available route are unaffected.
+func TestRouteSelector_SelectRoutes_AllUnavailableKeepsSelection(t *testing.T) {
+	allRoutes := []route.NetID{"route1", "route2", "route3"}
+
+	rs := routeselector.NewRouteSelector()
+	require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes))
+
+	err := rs.SelectRoutes([]route.NetID{"Route1", "route4"}, false, allRoutes)
+
+	assert.Error(t, err, "an unavailable route ID must still be reported")
+	assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request")
+	for _, id := range []route.NetID{"route2", "route3"} {
+		assert.False(t, rs.IsSelected(id), "no other route may become selected")
+	}
+}
+
+// Boundary of the check above: an empty request is the caller deselecting everything,
+// not a failed lookup, so it must keep working.
+func TestRouteSelector_SelectRoutes_EmptyRequestStillDeselectsAll(t *testing.T) {
+	allRoutes := []route.NetID{"route1", "route2", "route3"}
+
+	rs := routeselector.NewRouteSelector()
+	require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes))
+
+	require.NoError(t, rs.SelectRoutes(nil, false, allRoutes))
+
+	for _, id := range allRoutes {
+		assert.False(t, rs.IsSelected(id), "an empty selection request must deselect everything")
+	}
+}
+
+// Mobile clients always call SelectRoutes with append=true. On that path an
+// all-unavailable request was never destructive to begin with (append skips the
+// wipe regardless of the guard above), but the behavior has no coverage yet.
+func TestRouteSelector_SelectRoutes_AppendAllUnavailableKeepsSelection(t *testing.T) {
+	allRoutes := []route.NetID{"route1", "route2", "route3"}
+
+	rs := routeselector.NewRouteSelector()
+	require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes))
+
+	err := rs.SelectRoutes([]route.NetID{"missing"}, true, allRoutes)
+
+	assert.Error(t, err, "an unavailable route ID must still be reported")
+	assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request")
+	for _, id := range []route.NetID{"route2", "route3"} {
+		assert.False(t, rs.IsSelected(id), "no other route may become selected")
+	}
+}
+
+// The early return for an all-unavailable request must not clear deselectAll,
+// or a typo'd network ID would silently drop the "nothing selected, including
+// future networks" policy.
+func TestRouteSelector_SelectRoutes_AllUnavailableAfterDeselectAllKeepsPolicy(t *testing.T) {
+	allRoutes := []route.NetID{"route1", "route2"}
+
+	rs := routeselector.NewRouteSelector()
+	rs.DeselectAllRoutes()
+
+	err := rs.SelectRoutes([]route.NetID{"missing"}, false, allRoutes)
+
+	assert.Error(t, err, "an unavailable route ID must still be reported")
+	assert.True(t, rs.IsDeselectAll(), "deselect-all policy must survive a fully invalid request")
+	assert.False(t, rs.IsSelected("route3"), "deselect-all must still cover networks not present in allRoutes yet")
+}

From 1081ca006d46f26ea770602e2936012465d7163c Mon Sep 17 00:00:00 2001
From: Maycon Santos 
Date: Tue, 1 Sep 2026 11:45:20 +0200
Subject: [PATCH 24/40] [management,client] Add anonymize level and upload URL
 to remote debug bundle jobs (#7147)

This extends the management-requested remote debug-bundle job with two
new, optional parameters. anonymize_level selects how aggressively the
bundle is scrubbed: "default" keeps internal (private) IP ranges
readable, while "strict" also anonymizes private, CGNAT and link-local
addresses; the value is trimmed and lowercased, and an unknown level is
rejected at creation. upload_url lets an operator point the peer at a
specific upload service instead of the default one; it must be a
well-formed https URL with a host, and an empty value falls back to the
default upload server. Both fields flow through the job workload API and
are surfaced in the create-debug-job modal on the dashboard. Validation
is shared so the client executor and the management boundary agree on
what a valid upload URL is, preventing drift between the two checks.
---
 client/internal/engine.go                |   34 +-
 client/internal/engine_bundle_test.go    |   35 +
 client/jobexec/executor.go               |    8 +-
 management/server/types/job.go           |   38 +-
 management/server/types/job_test.go      |  137 ++
 shared/management/http/api/openapi.yml   |    8 +
 shared/management/http/api/types.gen.go  |    6 +
 shared/management/proto/management.pb.go | 2176 +++++++++++-----------
 shared/management/proto/management.proto |    3 +
 9 files changed, 1355 insertions(+), 1090 deletions(-)
 create mode 100644 client/internal/engine_bundle_test.go
 create mode 100644 management/server/types/job_test.go

diff --git a/client/internal/engine.go b/client/internal/engine.go
index fd2ac1d80..0cbf32fce 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -1373,7 +1373,17 @@ func (e *Engine) receiveJobEvents() {
 }
 
 func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) {
-	log.Infof("handle remote debug bundle request: %s", params.String())
+	// The upload URL can carry a host, credentials, or query tokens, so it is
+	// kept out of the info-level line; the full parameters stay available at
+	// debug level for troubleshooting.
+	log.Infof("handle remote debug bundle request: anonymize=%v anonymize_level=%q log_file_count=%d bundle_for=%v bundle_for_time=%d",
+		params.GetAnonymize(), params.GetAnonymizeLevel(), params.GetLogFileCount(), params.GetBundleFor(), params.GetBundleForTime())
+	log.Debugf("remote debug bundle request parameters: %s", params.String())
+
+	if err := validateBundleUploadURL(params.GetUploadUrl()); err != nil {
+		return nil, err
+	}
+
 	syncResponse, err := e.GetLatestSyncResponse()
 	if err != nil {
 		log.Warnf("get latest sync response: %v", err)
@@ -1401,7 +1411,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
 
 	waitFor := time.Duration(params.BundleForTime) * time.Minute
 
-	uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String())
+	uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), params.GetUploadUrl())
 	if err != nil {
 		return nil, err
 	}
@@ -1414,6 +1424,26 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
 	return response, nil
 }
 
+// validateBundleUploadURL sanity-checks a management-supplied upload URL for a
+// remote debug bundle job. An empty value is accepted — the executor falls back
+// to the default upload service. A non-empty value must be a well-formed https
+// URL with a host; a malformed value or a plaintext scheme is rejected. This
+// deliberately does not constrain which host may receive the bundle; that
+// policy is left open pending a decision on management-directed uploads.
+func validateBundleUploadURL(raw string) error {
+	if raw == "" {
+		return nil
+	}
+	parsed, err := url.Parse(raw)
+	if err != nil {
+		return fmt.Errorf("parse upload URL: %w", err)
+	}
+	if parsed.Scheme != "https" || parsed.Host == "" {
+		return fmt.Errorf("upload URL must be an https URL with a host")
+	}
+	return nil
+}
+
 // receiveManagementEvents connects to the Management Service event stream to receive updates from the management service
 // E.g. when a new peer has been registered and we are allowed to connect to it.
 func (e *Engine) receiveManagementEvents() {
diff --git a/client/internal/engine_bundle_test.go b/client/internal/engine_bundle_test.go
new file mode 100644
index 000000000..d736e2591
--- /dev/null
+++ b/client/internal/engine_bundle_test.go
@@ -0,0 +1,35 @@
+package internal
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestValidateBundleUploadURL covers the sanity check applied to a
+// management-supplied upload URL before a remote debug bundle is generated.
+func TestValidateBundleUploadURL(t *testing.T) {
+	for _, tc := range []struct {
+		name    string
+		raw     string
+		wantErr bool
+	}{
+		{name: "empty falls back to default", raw: ""},
+		{name: "https with host", raw: "https://upload.debug.netbird.io/upload"},
+		{name: "https self-hosted host", raw: "https://upload.example.com"},
+		{name: "plaintext rejected", raw: "http://upload.example.com", wantErr: true},
+		{name: "missing host rejected", raw: "https:///upload", wantErr: true},
+		{name: "non-url scheme rejected", raw: "ftp://upload.example.com", wantErr: true},
+		{name: "garbage rejected", raw: "://not a url", wantErr: true},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			err := validateBundleUploadURL(tc.raw)
+			if tc.wantErr {
+				require.Error(t, err, "an invalid upload URL must be rejected")
+				return
+			}
+			assert.NoError(t, err, "a valid or empty upload URL must be accepted")
+		})
+	}
+}
diff --git a/client/jobexec/executor.go b/client/jobexec/executor.go
index 9401acacc..7c730f757 100644
--- a/client/jobexec/executor.go
+++ b/client/jobexec/executor.go
@@ -28,7 +28,11 @@ func NewExecutor() *Executor {
 	return &Executor{}
 }
 
-func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL string) (string, error) {
+func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL, uploadURL string) (string, error) {
+	if uploadURL == "" {
+		uploadURL = types.DefaultBundleURL
+	}
+
 	if waitForDuration > MaxBundleWaitTime {
 		log.Warnf("bundle wait time %v exceeds maximum %v, capping to maximum", waitForDuration, MaxBundleWaitTime)
 		waitForDuration = MaxBundleWaitTime
@@ -54,7 +58,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.
 		}
 	}()
 
-	key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path, false)
+	key, err := debug.UploadDebugBundle(ctx, uploadURL, mgmURL, path, false)
 	if err != nil {
 		log.Errorf("failed to upload debug bundle: %v", err)
 		return "", fmt.Errorf("upload debug bundle: %w", err)
diff --git a/management/server/types/job.go b/management/server/types/job.go
index bad8f00ba..db2d0d42c 100644
--- a/management/server/types/job.go
+++ b/management/server/types/job.go
@@ -3,10 +3,12 @@ package types
 import (
 	"encoding/json"
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/google/uuid"
 
+	"github.com/netbirdio/netbird/client/anonymize"
 	"github.com/netbirdio/netbird/shared/management/http/api"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/management/status"
@@ -150,6 +152,21 @@ func validateAndBuildBundleParams(req api.WorkloadRequest, workload *Workload) e
 	if bundle.Parameters.LogFileCount < 1 || bundle.Parameters.LogFileCount > 1000 {
 		return fmt.Errorf("log-file-count must be between 1 and 1000, got %d", bundle.Parameters.LogFileCount)
 	}
+	// validate anonymize_level: omitted or empty defaults on the client;
+	// otherwise it must name a known level. An unknown value is rejected here
+	// rather than silently escalated, so a typo surfaces at job creation. The
+	// normalized (trimmed, lowercased) value is persisted so it matches what
+	// the client parses — the client only lowercases, so a stored " default "
+	// would otherwise resolve to strict.
+	if lvl := bundle.Parameters.AnonymizeLevel; lvl != nil {
+		normalized := strings.ToLower(strings.TrimSpace(*lvl))
+		switch normalized {
+		case "", anonymize.LevelDefaultString, anonymize.LevelStrictString:
+		default:
+			return fmt.Errorf("anonymize_level must be %q or %q, got %q", anonymize.LevelDefaultString, anonymize.LevelStrictString, *lvl)
+		}
+		bundle.Parameters.AnonymizeLevel = &normalized
+	}
 
 	workload.Parameters, err = json.Marshal(bundle.Parameters)
 	if err != nil {
@@ -209,6 +226,17 @@ func (j *Job) ToStreamJobRequest() (*proto.JobRequest, error) {
 	}
 }
 
+// derefString returns the pointed-to string, or "" when the pointer is nil.
+// The bundle parameters carry anonymize_level and upload_url as optional
+// fields; an absent value maps to the empty proto string, which the client
+// resolves to its default.
+func derefString(s *string) string {
+	if s == nil {
+		return ""
+	}
+	return *s
+}
+
 func (j *Job) buildStreamBundleResponse() (*proto.JobRequest, error) {
 	var p api.BundleParameters
 	if err := json.Unmarshal(j.Workload.Parameters, &p); err != nil {
@@ -218,10 +246,12 @@ func (j *Job) buildStreamBundleResponse() (*proto.JobRequest, error) {
 		ID: []byte(j.ID),
 		WorkloadParameters: &proto.JobRequest_Bundle{
 			Bundle: &proto.BundleParameters{
-				BundleFor:     p.BundleFor,
-				BundleForTime: int64(p.BundleForTime),
-				LogFileCount:  int32(p.LogFileCount),
-				Anonymize:     p.Anonymize,
+				BundleFor:      p.BundleFor,
+				BundleForTime:  int64(p.BundleForTime),
+				LogFileCount:   int32(p.LogFileCount),
+				Anonymize:      p.Anonymize,
+				AnonymizeLevel: derefString(p.AnonymizeLevel),
+				UploadUrl:      derefString(p.UploadUrl),
 			},
 		},
 	}, nil
diff --git a/management/server/types/job_test.go b/management/server/types/job_test.go
new file mode 100644
index 000000000..428c2215b
--- /dev/null
+++ b/management/server/types/job_test.go
@@ -0,0 +1,137 @@
+package types
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+func strPtr(s string) *string { return &s }
+
+// bundleJobFromParams builds a bundle Job whose stored workload parameters are
+// the marshalled REST BundleParameters, mirroring what NewJob persists.
+func bundleJobFromParams(t *testing.T, p api.BundleParameters) *Job {
+	t.Helper()
+	raw, err := json.Marshal(p)
+	require.NoError(t, err, "marshal bundle parameters")
+	return &Job{
+		ID: "job-1",
+		Workload: Workload{
+			Type:       JobTypeBundle,
+			Parameters: raw,
+			Result:     []byte("{}"),
+		},
+	}
+}
+
+// TestBuildStreamBundleResponse_CarriesIdentityAndUploadFields verifies the
+// anonymize_level and upload_url REST fields are mapped onto the proto request
+// the client receives.
+func TestBuildStreamBundleResponse_CarriesIdentityAndUploadFields(t *testing.T) {
+	job := bundleJobFromParams(t, api.BundleParameters{
+		BundleFor:      true,
+		BundleForTime:  2,
+		LogFileCount:   100,
+		Anonymize:      true,
+		AnonymizeLevel: strPtr("strict"),
+		UploadUrl:      strPtr("https://upload.example.com"),
+	})
+
+	req, err := job.ToStreamJobRequest()
+	require.NoError(t, err, "ToStreamJobRequest must succeed")
+
+	bundle := req.GetBundle()
+	require.NotNil(t, bundle, "the request must carry bundle parameters")
+	assert.Equal(t, "strict", bundle.GetAnonymizeLevel(), "anonymize_level must reach the client")
+	assert.Equal(t, "https://upload.example.com", bundle.GetUploadUrl(), "upload_url must reach the client")
+	assert.True(t, bundle.GetAnonymize(), "existing fields must still map")
+	assert.Equal(t, int32(100), bundle.GetLogFileCount(), "existing fields must still map")
+}
+
+// newBundleJobRequest builds an api.JobRequest carrying a bundle workload with
+// the given parameters, mirroring what the REST handler decodes.
+func newBundleJobRequest(t *testing.T, p api.BundleParameters) *api.JobRequest {
+	t.Helper()
+	var wr api.WorkloadRequest
+	require.NoError(t, wr.FromBundleWorkloadRequest(api.BundleWorkloadRequest{
+		Type:       api.WorkloadTypeBundle,
+		Parameters: p,
+	}), "build bundle workload request")
+	return &api.JobRequest{Workload: wr}
+}
+
+// TestNewJob_AnonymizeLevelValidation verifies the management API accepts only
+// known anonymization levels (empty defaults on the client) and rejects an
+// unknown value instead of silently escalating it.
+func TestNewJob_AnonymizeLevelValidation(t *testing.T) {
+	base := api.BundleParameters{BundleFor: false, LogFileCount: 100, Anonymize: true}
+
+	for _, tc := range []struct {
+		name    string
+		level   *string
+		wantErr bool
+	}{
+		{name: "omitted", level: nil},
+		{name: "empty", level: strPtr("")},
+		{name: "default", level: strPtr("default")},
+		{name: "strict", level: strPtr("strict")},
+		{name: "mixed case", level: strPtr("Strict")},
+		{name: "padded", level: strPtr(" default ")},
+		{name: "unknown", level: strPtr("verbose"), wantErr: true},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			p := base
+			p.AnonymizeLevel = tc.level
+			_, err := NewJob("user-1", "acc-1", "peer-1", newBundleJobRequest(t, p))
+			if tc.wantErr {
+				require.Error(t, err, "an unknown anonymize_level must be rejected")
+				assert.Contains(t, err.Error(), "anonymize_level", "the error must name the offending field")
+				return
+			}
+			require.NoError(t, err, "a known anonymize_level must be accepted")
+		})
+	}
+}
+
+// TestNewJob_AnonymizeLevelNormalized verifies an accepted level is persisted
+// trimmed and lowercased, so it reaches the client as a value the client's
+// lowercase-only parser resolves correctly rather than escalating to strict.
+func TestNewJob_AnonymizeLevelNormalized(t *testing.T) {
+	job, err := NewJob("user-1", "acc-1", "peer-1", newBundleJobRequest(t, api.BundleParameters{
+		BundleFor:      false,
+		LogFileCount:   100,
+		Anonymize:      true,
+		AnonymizeLevel: strPtr("  Default  "),
+	}))
+	require.NoError(t, err, "a padded known level must be accepted")
+
+	req, err := job.ToStreamJobRequest()
+	require.NoError(t, err, "ToStreamJobRequest must succeed")
+	assert.Equal(t, "default", req.GetBundle().GetAnonymizeLevel(),
+		"the persisted level must be normalized so the client does not resolve it to strict")
+}
+
+// TestBuildStreamBundleResponse_OmittedFieldsMapToEmpty verifies that omitted
+// optional fields map to the empty proto string, which the client resolves to
+// its defaults (default anonymization level, default upload server).
+func TestBuildStreamBundleResponse_OmittedFieldsMapToEmpty(t *testing.T) {
+	job := bundleJobFromParams(t, api.BundleParameters{
+		BundleFor:     false,
+		BundleForTime: 1,
+		LogFileCount:  50,
+		Anonymize:     false,
+		// AnonymizeLevel and UploadUrl intentionally nil.
+	})
+
+	req, err := job.ToStreamJobRequest()
+	require.NoError(t, err, "ToStreamJobRequest must succeed")
+
+	bundle := req.GetBundle()
+	require.NotNil(t, bundle, "the request must carry bundle parameters")
+	assert.Empty(t, bundle.GetAnonymizeLevel(), "an omitted anonymize_level must map to empty so the client defaults it")
+	assert.Empty(t, bundle.GetUploadUrl(), "an omitted upload_url must map to empty so the client defaults it")
+}
diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml
index 3ab5a2e42..142d9a562 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -154,6 +154,14 @@ components:
           type: boolean
           description: Whether sensitive data should be anonymized in the bundle.
           example: false
+        anonymize_level:
+          type: string
+          description: How much the anonymizer redacts. "default" (or empty) keeps internal IP ranges, "strict" also anonymizes them.
+          example: strict
+        upload_url:
+          type: string
+          description: Service URL the client requests an upload URL from before uploading the bundle. Empty selects the default upload server.
+          example: https://upload.debug.netbird.io
       required:
         - bundle_for
         - bundle_for_time
diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go
index db5b2e18e..3fc3c4ef3 100644
--- a/shared/management/http/api/types.gen.go
+++ b/shared/management/http/api/types.gen.go
@@ -2575,6 +2575,9 @@ type BundleParameters struct {
 	// Anonymize Whether sensitive data should be anonymized in the bundle.
 	Anonymize bool `json:"anonymize"`
 
+	// AnonymizeLevel How much the anonymizer redacts. "default" (or empty) keeps internal IP ranges, "strict" also anonymizes them.
+	AnonymizeLevel *string `json:"anonymize_level,omitempty"`
+
 	// BundleFor Whether to generate a bundle for the given timeframe.
 	BundleFor bool `json:"bundle_for"`
 
@@ -2583,6 +2586,9 @@ type BundleParameters struct {
 
 	// LogFileCount Maximum number of log files to include in the bundle.
 	LogFileCount int `json:"log_file_count"`
+
+	// UploadUrl Service URL the client requests an upload URL from before uploading the bundle. Empty selects the default upload server.
+	UploadUrl *string `json:"upload_url,omitempty"`
 }
 
 // BundleResult defines model for BundleResult.
diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go
index bd3ec7120..74b469ee8 100644
--- a/shared/management/proto/management.pb.go
+++ b/shared/management/proto/management.pb.go
@@ -738,6 +738,9 @@ type BundleParameters struct {
 	// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
 	// Unknown values are treated as "strict".
 	AnonymizeLevel string `protobuf:"bytes,5,opt,name=anonymize_level,json=anonymizeLevel,proto3" json:"anonymize_level,omitempty"`
+	// upload_url is the service URL the client requests an upload URL from
+	// before uploading the bundle. Empty selects the default upload server.
+	UploadUrl string `protobuf:"bytes,6,opt,name=upload_url,json=uploadUrl,proto3" json:"upload_url,omitempty"`
 }
 
 func (x *BundleParameters) Reset() {
@@ -807,6 +810,13 @@ func (x *BundleParameters) GetAnonymizeLevel() string {
 	return ""
 }
 
+func (x *BundleParameters) GetUploadUrl() string {
+	if x != nil {
+		return x.UploadUrl
+	}
+	return ""
+}
+
 type BundleResult struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -6841,7 +6851,7 @@ var file_management_proto_rawDesc = []byte{
 	0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x42,
 	0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x06, 0x62,
 	0x75, 0x6e, 0x64, 0x6c, 0x65, 0x42, 0x12, 0x0a, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x6c, 0x6f, 0x61,
-	0x64, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0xc6, 0x01, 0x0a, 0x10, 0x42, 0x75,
+	0x64, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0xe5, 0x01, 0x0a, 0x10, 0x42, 0x75,
 	0x6e, 0x64, 0x6c, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x1d,
 	0x0a, 0x0a, 0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x5f, 0x66, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01,
 	0x28, 0x08, 0x52, 0x09, 0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x46, 0x6f, 0x72, 0x12, 0x26, 0x0a,
@@ -6854,1107 +6864,1109 @@ var file_management_proto_rawDesc = []byte{
 	0x61, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x61, 0x6e, 0x6f,
 	0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01,
 	0x28, 0x09, 0x52, 0x0e, 0x61, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x4c, 0x65, 0x76,
-	0x65, 0x6c, 0x22, 0x2d, 0x0a, 0x0c, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75,
-	0x6c, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6b, 0x65, 0x79,
-	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x4b, 0x65,
-	0x79, 0x22, 0x3d, 0x0a, 0x0b, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
-	0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72,
-	0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61,
-	0x22, 0x8d, 0x04, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
-	0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66,
-	0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e,
-	0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66,
-	0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 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, 0x3e, 0x0a, 0x0b, 0x72, 0x65,
-	0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, 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, 0x0b, 0x72,
-	0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65,
-	0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79,
-	0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65,
-	0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x36, 0x0a, 0x0a, 0x4e, 0x65,
-	0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77,
-	0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x52, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d,
-	0x61, 0x70, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x06, 0x20, 0x03,
-	0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x46,
-	0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73,
-	0x41, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
-	0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
-	0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70,
-	0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x4e, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
-	0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x18, 0x08, 0x20, 0x01,
-	0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f,
-	0x70, 0x65, 0x52, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e,
-	0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
-	0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
-	0x22, 0x41, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75,
-	0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
-	0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d,
-	0x65, 0x74, 0x61, 0x22, 0xc6, 0x01, 0x0a, 0x0c, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71,
-	0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79,
-	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79,
+	0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x75, 0x72, 0x6c,
+	0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x55, 0x72,
+	0x6c, 0x22, 0x2d, 0x0a, 0x0c, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x75, 0x6c,
+	0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x4b, 0x65, 0x79,
+	0x22, 0x3d, 0x0a, 0x0b, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
+	0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53,
+	0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22,
+	0x8d, 0x04, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+	0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+	0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+	0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 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, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d,
+	0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, 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, 0x0b, 0x72, 0x65,
+	0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d,
+	0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18,
+	0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
+	0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x36, 0x0a, 0x0a, 0x4e, 0x65, 0x74,
+	0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
+	0x72, 0x6b, 0x4d, 0x61, 0x70, 0x52, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61,
+	0x70, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43,
+	0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x46, 0x0a,
+	0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41,
+	0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
+	0x61, 0x6d, 0x70, 0x52, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69,
+	0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x4e, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
+	0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e,
+	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70,
+	0x65, 0x52, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76,
+	0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+	0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22,
+	0x41, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65,
+	0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65,
+	0x74, 0x61, 0x22, 0xc6, 0x01, 0x0a, 0x0c, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12,
+	0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53,
+	0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12,
+	0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x70,
+	0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x4b,
+	0x65, 0x79, 0x73, 0x52, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a,
+	0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09,
+	0x52, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x22, 0x44, 0x0a, 0x08, 0x50,
+	0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75,
+	0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50,
+	0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65,
+	0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65,
+	0x79, 0x22, 0x3f, 0x0a, 0x0b, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74,
+	0x12, 0x14, 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f,
+	0x72, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f,
+	0x72, 0x6d, 0x22, 0x5c, 0x0a, 0x04, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61,
+	0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x14,
+	0x0a, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65,
+	0x78, 0x69, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49,
+	0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10,
+	0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67,
+	0x22, 0xe1, 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f,
+	0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45,
+	0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70,
+	0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65,
+	0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76,
+	0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c,
+	0x6f, 0x77, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43,
+	0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74,
+	0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c,
+	0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20,
+	0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76,
+	0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x61,
+	0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x64, 0x69,
+	0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x12, 0x28, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x61,
+	0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61,
+	0x6c, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63,
+	0x63, 0x65, 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63,
+	0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x62, 0x6c,
+	0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34,
+	0x0a, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e,
+	0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c,
+	0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61,
+	0x62, 0x6c, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53,
+	0x48, 0x52, 0x6f, 0x6f, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61,
+	0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e,
+	0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50,
+	0x12, 0x42, 0x0a, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63,
+	0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67,
+	0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53,
+	0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72,
+	0x64, 0x69, 0x6e, 0x67, 0x12, 0x44, 0x0a, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53,
+	0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61,
+	0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61,
+	0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74,
+	0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69,
+	0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75,
+	0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76,
+	0x36, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
+	0x49, 0x50, 0x76, 0x36, 0x22, 0xe2, 0x05, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73,
+	0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
+	0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
+	0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65,
+	0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12,
+	0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63,
+	0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18,
+	0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12,
+	0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12,
+	0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+	0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64,
+	0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72,
+	0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56, 0x65,
+	0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56,
+	0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65,
+	0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f,
+	0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+	0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74,
+	0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52,
+	0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65,
+	0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75,
+	0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53,
+	0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73,
+	0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e,
+	0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61,
+	0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79,
+	0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a,
+	0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+	0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76,
+	0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65,
+	0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73,
+	0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61,
+	0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70,
+	0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32,
+	0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65,
+	0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70,
+	0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x73, 0x79, 0x6e,
+	0x63, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18,
+	0x13, 0x20, 0x01, 0x28, 0x05, 0x52, 0x12, 0x73, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x73, 0x73, 0x61,
+	0x67, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xfc, 0x01, 0x0a, 0x0d, 0x4c, 0x6f,
+	0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e,
+	0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+	0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e,
+	0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a,
+	0x70, 0x65, 0x65, 0x72, 0x43, 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, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03,
+	0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73,
+	0x12, 0x46, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72,
+	0x65, 0x73, 0x41, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f,
+	0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d,
+	0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45,
+	0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x66, 0x0a, 0x18, 0x45, 0x78, 0x74, 0x65,
+	0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
 	0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
 	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72,
 	0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61,
-	0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x08,
-	0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72,
-	0x4b, 0x65, 0x79, 0x73, 0x52, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c,
-	0x0a, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28,
-	0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x22, 0x44, 0x0a, 0x08,
-	0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50,
-	0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68,
-	0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b,
-	0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b,
-	0x65, 0x79, 0x22, 0x3f, 0x0a, 0x0b, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e,
-	0x74, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66,
-	0x6f, 0x72, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66,
-	0x6f, 0x72, 0x6d, 0x22, 0x5c, 0x0a, 0x04, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70,
-	0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12,
-	0x14, 0x0a, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05,
-	0x65, 0x78, 0x69, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73,
-	0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52,
-	0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e,
-	0x67, 0x22, 0xe1, 0x05, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72,
-	0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18,
-	0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73,
-	0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
-	0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x02,
-	0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50,
-	0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72,
-	0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20,
-	0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c,
-	0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
-	0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01,
-	0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e,
-	0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62,
-	0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05,
-	0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72,
-	0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73,
-	0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x64,
-	0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x12, 0x28, 0x0a, 0x0f, 0x64, 0x69, 0x73,
-	0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x18, 0x07, 0x20, 0x01,
-	0x28, 0x08, 0x52, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77,
-	0x61, 0x6c, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41,
-	0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x62, 0x6c, 0x6f,
-	0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x62,
-	0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28,
-	0x08, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12,
-	0x34, 0x0a, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f,
-	0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15,
-	0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e,
-	0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53,
-	0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e,
-	0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x65,
-	0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x18, 0x0c, 0x20, 0x01,
-	0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54,
-	0x50, 0x12, 0x42, 0x0a, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f,
-	0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e,
-	0x67, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53,
-	0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61,
-	0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x44, 0x0a, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53,
-	0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77,
-	0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1d, 0x65, 0x6e,
-	0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72,
-	0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x64,
-	0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0f, 0x20,
-	0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41,
-	0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50,
-	0x76, 0x36, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c,
-	0x65, 0x49, 0x50, 0x76, 0x36, 0x22, 0xe2, 0x05, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79,
-	0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74,
-	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74,
-	0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e,
-	0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c,
-	0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
-	0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d,
-	0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d,
-	0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53,
-	0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69,
-	0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72,
-	0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65,
-	0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56,
-	0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c,
-	0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b,
-	0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09,
-	0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65,
-	0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b,
-	0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
-	0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
-	0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e,
-	0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73,
-	0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e,
-	0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74,
-	0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66,
-	0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73,
-	0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39,
-	0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20,
-	0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
-	0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e,
-	0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c,
-	0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65,
-	0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b,
-	0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c,
-	0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x3e, 0x0a, 0x0c, 0x63, 0x61,
-	0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0e,
-	0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65,
-	0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63, 0x61,
-	0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x73, 0x79,
-	0x6e, 0x63, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
-	0x18, 0x13, 0x20, 0x01, 0x28, 0x05, 0x52, 0x12, 0x73, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x73, 0x73,
-	0x61, 0x67, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xfc, 0x01, 0x0a, 0x0d, 0x4c,
-	0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d,
-	0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
-	0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d,
-	0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a,
-	0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 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, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18,
-	0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b,
-	0x73, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69,
-	0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
-	0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
-	0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
-	0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x66, 0x0a, 0x18, 0x45, 0x78, 0x74,
-	0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65,
-	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65,
-	0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65,
-	0x6e, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
-	0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65,
-	0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74,
-	0x61, 0x22, 0x63, 0x0a, 0x19, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53,
-	0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46,
-	0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73,
-	0x41, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
-	0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
-	0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70,
-	0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
-	0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b,
-	0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a,
-	0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
-	0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
-	0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78,
-	0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69,
-	0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
-	0x6e, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xb4, 0x02, 0x0a, 0x0d, 0x4e,
-	0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05,
-	0x73, 0x74, 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61,
-	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e,
-	0x66, 0x69, 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75,
-	0x72, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64,
-	0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e,
-	0x73, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48,
-	0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61,
-	0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b,
-	0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65,
-	0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79,
-	0x12, 0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77,
-	0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x12, 0x33, 0x0a, 0x07,
-	0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e,
-	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69,
-	0x63, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63,
-	0x73, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
-	0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75,
-	0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02,
-	0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f,
-	0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22,
-	0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55,
-	0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a,
-	0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53,
-	0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b,
-	0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75,
-	0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12,
-	0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18,
-	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c,
-	0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e,
-	0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b,
-	0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a,
-	0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72,
-	0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c,
-	0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,
-	0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75,
-	0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53,
-	0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65,
-	0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f,
-	0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12,
-	0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08,
-	0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75,
-	0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75,
-	0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64,
-	0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28,
-	0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65,
-	0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c,
-	0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e,
-	0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x29, 0x0a, 0x0d, 0x4d,
-	0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07,
-	0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65,
-	0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f,
-	0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08,
-	0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
-	0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73,
-	0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
-	0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b,
-	0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28,
-	0x03, 0x52, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c,
-	0x0a, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28,
-	0x09, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13,
-	0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e,
-	0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
-	0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
-	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
-	0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75,
-	0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12,
-	0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a,
-	0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64,
-	0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64,
-	0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 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, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
-	0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66,
-	0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12,
-	0x48, 0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e,
-	0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c,
-	0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e,
-	0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69,
-	0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a,
-	0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c,
-	0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f,
-	0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12,
-	0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74,
-	0x75, 0x12, 0x3e, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18,
-	0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74,
-	0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74,
-	0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18,
-	0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36,
-	0x22, 0x52, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65,
-	0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
-	0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
-	0x12, 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
-	0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70,
-	0x64, 0x61, 0x74, 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
-	0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70,
-	0x65, 0x65, 0x72, 0x43, 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, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
-	0x72, 0x73, 0x18, 0x03, 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, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65,
-	0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
-	0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52,
-	0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d,
-	0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 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, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33,
-	0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44,
-	0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e,
-	0x66, 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65,
-	0x65, 0x72, 0x73, 0x18, 0x07, 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, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
-	0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 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, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c,
-	0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
-	0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20,
-	0x01, 0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c,
-	0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75,
-	0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73,
-	0x18, 0x0a, 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, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72,
-	0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f,
-	0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65,
-	0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a,
-	0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75,
-	0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f,
-	0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 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,
-	0x12, 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53,
-	0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22,
-	0x82, 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55,
-	0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a,
-	0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73,
-	0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a,
-	0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69,
-	0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41,
-	0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73,
-	0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73,
-	0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73,
-	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, 0x34, 0x0a, 0x05, 0x76, 0x61,
-	0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73,
-	0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
-	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, 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,
-	0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77,
-	0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66,
+	0x22, 0x63, 0x0a, 0x19, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65,
+	0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a,
+	0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41,
+	0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
+	0x61, 0x6d, 0x70, 0x52, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69,
+	0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b,
+	0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
+	0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09,
+	0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
+	0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70,
+	0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
+	0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+	0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xb4, 0x02, 0x0a, 0x0d, 0x4e, 0x65,
+	0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73,
+	0x74, 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72,
+	0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48,
+	0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73,
+	0x12, 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f,
+	0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c,
+	0x12, 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c,
+	0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12,
+	0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43,
+	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x12, 0x33, 0x0a, 0x07, 0x6d,
+	0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63,
+	0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73,
+	0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
+	0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72,
+	0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74,
+	0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b,
+	0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44,
+	0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04,
+	0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10,
+	0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52,
+	0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72,
+	0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22,
+	0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f,
+	0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61,
+	0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65,
+	0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46,
+	0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74,
+	0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12,
+	0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72,
+	0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69,
+	0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72,
+	0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18,
+	0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e,
+	0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e,
+	0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65,
+	0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63,
+	0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65,
+	0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73,
+	0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x29, 0x0a, 0x0d, 0x4d, 0x65,
+	0x74, 0x72, 0x69, 0x63, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x65,
+	0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e,
+	0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61,
+	0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61,
+	0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c,
+	0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b,
+	0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d,
+	0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03,
+	0x52, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a,
+	0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09,
+	0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50,
+	0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+	0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a,
+	0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73,
+	0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a,
+	0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, 0x50,
+	0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64,
+	0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72,
+	0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 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, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
 	0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71,
-	0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22,
-	0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69,
-	0x6f, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18,
-	0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x09, 0x6c, 0x61,
-	0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f,
-	0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c,
-	0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61,
-	0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65,
-	0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b,
-	0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18,
-	0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77,
-	0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63,
-	0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c,
-	0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65,
-	0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f,
-	0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65,
-	0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
-	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f,
-	0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f,
-	0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12,
-	0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
-	0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
-	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e,
-	0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e,
-	0x66, 0x69, 0x67, 0x22, 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,
+	0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48,
+	0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73,
+	0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65,
+	0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67,
+	0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f,
+	0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79,
+	0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65,
+	0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e,
+	0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10,
+	0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75,
+	0x12, 0x3e, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74,
+	0x69, 0x6e, 0x67, 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
+	0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, 0x09,
+	0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, 0x22,
+	0x52, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74,
+	0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
+	0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64,
+	0x61, 0x74, 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d,
+	0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65,
+	0x65, 0x72, 0x43, 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, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72,
+	0x73, 0x18, 0x03, 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, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
+	0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72,
+	0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12,
+	0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70,
+	0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 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, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a,
+	0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e,
+	0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65,
+	0x72, 0x73, 0x18, 0x07, 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, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c,
+	0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 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, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52,
+	0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c,
+	0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65,
+	0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74,
+	0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18,
+	0x0a, 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, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65,
+	0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75,
+	0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73,
+	0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72,
+	0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c,
+	0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72,
+	0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 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, 0x12,
+	0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53,
+	0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82,
+	0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73,
+	0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f,
+	0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18,
+	0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65,
+	0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e,
+	0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75,
+	0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45,
+	0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65,
+	0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 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, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c,
+	0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65,
+	0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 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, 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, 0x49,
+	0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65,
+	0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 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, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09,
+	0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64,
+	0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a,
+	0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+	0x6e, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x06,
+	0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x09, 0x6c, 0x61, 0x7a,
+	0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65,
+	0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62,
+	0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65,
+	0x79, 0x12, 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74,
+	0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65,
+	0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f,
+	0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76,
+	0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+	0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72,
+	0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76,
+	0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42,
+	0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+	0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x22, 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,
-	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,
+	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, 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,
+	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, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e,
+	0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b,
+	0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73,
+	0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72,
+	0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72,
+	0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73,
+	0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75,
+	0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61,
+	0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c,
+	0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d,
+	0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a,
+	0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63,
+	0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65,
+	0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72,
+	0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a,
+	0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f,
+	0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61,
+	0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f,
+	0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10,
+	0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64,
+	0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15,
+	0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73,
+	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70,
+	0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f,
+	0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61,
+	0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74,
+	0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12,
+	0x3a, 0x0a, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
+	0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46,
+	0x75, 0x6c, 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x12, 0x3d, 0x0a, 0x05, 0x64,
+	0x65, 0x6c, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d,
+	0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74,
+	0x61, 0x48, 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61,
+	0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x92, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
+	0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75,
+	0x6c, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x04, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65,
+	0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65,
+	0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
+	0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x4d, 0x0a, 0x10, 0x61, 0x63, 0x63,
+	0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x04, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73,
+	0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74,
+	0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f,
+	0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53,
+	0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0b,
+	0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x64,
+	0x6e, 0x73, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x09, 0x64, 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x63, 0x75,
+	0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
+	0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f,
+	0x6e, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e,
+	0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09,
+	0x52, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12,
+	0x2d, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72,
+	0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e,
+	0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e,
+	0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x11, 0x72, 0x6f, 0x75,
+	0x74, 0x65, 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x35,
+	0x0a, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f,
+	0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x08, 0x70, 0x6f, 0x6c,
+	0x69, 0x63, 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18,
+	0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+	0x6e, 0x74, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52,
+	0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65,
+	0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77, 0x52, 0x06, 0x72,
+	0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72,
+	0x76, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61,
+	0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77,
+	0x52, 0x10, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75,
+	0x70, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x6e, 0x73, 0x5f, 0x72, 0x65,
+	0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61,
+	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52,
+	0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x63,
+	0x6f, 0x72, 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f,
+	0x7a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61,
+	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a,
+	0x6f, 0x6e, 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5a, 0x6f, 0x6e, 0x65,
+	0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x73,
+	0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
+	0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x65,
+	0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x55,
+	0x0a, 0x0b, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x12, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f,
+	0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72,
+	0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x72, 0x6f, 0x75, 0x74, 0x65,
+	0x72, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
+	0x65, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x13,
+	0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70,
+	0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75,
+	0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e,
+	0x74, 0x72, 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c,
+	0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, 0x0a, 0x14, 0x67, 0x72, 0x6f, 0x75,
+	0x70, 0x5f, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73,
+	0x18, 0x14, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+	0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f,
+	0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x47, 0x72, 0x6f,
+	0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74,
+	0x72, 0x79, 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65,
+	0x72, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f,
+	0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e,
+	0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x6e,
+	0x0a, 0x14, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64,
+	0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
+	0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75,
+	0x6c, 0x6c, 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64,
+	0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x70, 0x6f, 0x73, 0x74,
+	0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2c,
+	0x0a, 0x12, 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x5f,
+	0x70, 0x6f, 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x64, 0x6e, 0x73, 0x46,
+	0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x37, 0x0a, 0x0b,
+	0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, 0x68, 0x18, 0x18, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
+	0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x78, 0x79,
+	0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
+	0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75, 0x73,
+	0x65, 0x72, 0x49, 0x64, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, 0x52, 0x6f, 0x75,
+	0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03,
+	0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33,
+	0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
+	0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61,
+	0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x75,
+	0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e,
+	0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c,
+	0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49,
+	0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
+	0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
+	0x79, 0x12, 0x2c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73,
+	0x65, 0x72, 0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
+	0x02, 0x38, 0x01, 0x1a, 0x5f, 0x0a, 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61,
+	0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
+	0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
+	0x12, 0x2e, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65,
+	0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
+	0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, 0x08, 0x1a, 0x10, 0x33, 0x22, 0x87, 0x03, 0x0a, 0x0a, 0x50,
+	0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x65, 0x65,
+	0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72,
+	0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x41, 0x0a,
+	0x0d, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x02,
+	0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73,
+	0x12, 0x3f, 0x0a, 0x0e, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c,
+	0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75,
+	0x6c, 0x65, 0x52, 0x0d, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65,
+	0x73, 0x12, 0x29, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52,
+	0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4f, 0x0a, 0x14,
+	0x72, 0x6f, 0x75, 0x74, 0x65, 0x5f, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72,
+	0x75, 0x6c, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72,
+	0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, 0x75, 0x74, 0x65,
+	0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x45, 0x0a,
+	0x10, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65,
+	0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52,
+	0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52,
+	0x75, 0x6c, 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74,
+	0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12,
+	0x41, 0x0a, 0x1d, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78,
+	0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x70, 0x65, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x69,
+	0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c,
+	0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e,
+	0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x73, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x70, 0x65, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45,
+	0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x73, 0x22, 0x95, 0x01, 0x0a, 0x0e,
+	0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x1e,
+	0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x19,
+	0x0a, 0x08, 0x6e, 0x65, 0x74, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x07, 0x6e, 0x65, 0x74, 0x43, 0x69, 0x64, 0x72, 0x12, 0x1e, 0x0a, 0x0b, 0x6e, 0x65, 0x74,
+	0x5f, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+	0x6e, 0x65, 0x74, 0x56, 0x36, 0x43, 0x69, 0x64, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x73,
+	0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x73, 0x65, 0x72,
+	0x69, 0x61, 0x6c, 0x22, 0x21, 0x0a, 0x19, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61,
+	0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74, 0x61,
+	0x4a, 0x04, 0x08, 0x01, 0x10, 0x65, 0x22, 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, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69,
-	0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e,
-	0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50,
-	0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65,
-	0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11,
-	0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
-	0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50,
-	0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52,
-	0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22,
-	0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
-	0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74,
-	0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08,
-	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f,
-	0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74,
-	0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f,
-	0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f,
-	0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70,
-	0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f,
-	0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e,
-	0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b,
-	0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28,
-	0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01,
-	0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
-	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69,
-	0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73,
-	0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65,
-	0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64,
-	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d,
-	0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f,
-	0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52,
-	0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65,
-	0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
-	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69,
-	0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22,
-	0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65,
-	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78,
-	0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64,
-	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d,
-	0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73,
-	0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x12, 0x4e, 0x65,
-	0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65,
-	0x12, 0x3a, 0x0a, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77,
-	0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73,
-	0x46, 0x75, 0x6c, 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x12, 0x3d, 0x0a, 0x05,
-	0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61,
-	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
-	0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, 0x6c,
-	0x74, 0x61, 0x48, 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x42, 0x09, 0x0a, 0x07, 0x70,
-	0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x92, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f,
-	0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46,
-	0x75, 0x6c, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x04, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x37, 0x0a, 0x0b, 0x70,
-	0x65, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
-	0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65,
-	0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f,
-	0x6e, 0x66, 0x69, 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18,
-	0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
-	0x6b, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x4d, 0x0a, 0x10, 0x61, 0x63,
-	0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x04,
-	0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67,
-	0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e,
-	0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x64, 0x6e, 0x73,
-	0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32,
-	0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53,
-	0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52,
-	0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1d, 0x0a, 0x0a,
-	0x64, 0x6e, 0x73, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x09, 0x64, 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x63,
-	0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69,
-	0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a,
-	0x6f, 0x6e, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x65,
-	0x6e, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28,
-	0x09, 0x52, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73,
-	0x12, 0x2d, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32,
-	0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65,
-	0x72, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12,
-	0x2e, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69,
-	0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x11, 0x72, 0x6f,
-	0x75, 0x74, 0x65, 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12,
-	0x35, 0x0a, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28,
-	0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
-	0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x08, 0x70, 0x6f,
-	0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73,
-	0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
-	0x65, 0x6e, 0x74, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74,
-	0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74,
-	0x65, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77, 0x52, 0x06,
-	0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65,
-	0x72, 0x76, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28,
-	0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e,
-	0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61,
-	0x77, 0x52, 0x10, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f,
-	0x75, 0x70, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x6e, 0x73, 0x5f, 0x72,
-	0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65,
-	0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, 0x44, 0x6e, 0x73, 0x52, 0x65,
-	0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74,
-	0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d,
-	0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5a, 0x6f, 0x6e,
-	0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x72, 0x65,
-	0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e,
-	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
-	0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e,
-	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12,
-	0x55, 0x0a, 0x0b, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x12,
-	0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70,
-	0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65,
-	0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x72, 0x6f, 0x75, 0x74,
-	0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
-	0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18,
-	0x13, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d,
-	0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x65, 0x73, 0x6f,
-	0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45,
-	0x6e, 0x74, 0x72, 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f,
-	0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, 0x0a, 0x14, 0x67, 0x72, 0x6f,
-	0x75, 0x70, 0x5f, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
-	0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
-	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43,
-	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x47, 0x72,
-	0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e,
-	0x74, 0x72, 0x79, 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73,
-	0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64,
-	0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x09, 0x52,
-	0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12,
-	0x6e, 0x0a, 0x14, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65,
-	0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3c, 0x2e,
-	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
-	0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46,
-	0x75, 0x6c, 0x6c, 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65,
-	0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x70, 0x6f, 0x73,
-	0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12,
-	0x2c, 0x0a, 0x12, 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72,
-	0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x64, 0x6e, 0x73,
-	0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x37, 0x0a,
-	0x0b, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, 0x68, 0x18, 0x18, 0x20, 0x01,
-	0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x78,
-	0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
-	0x64, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75,
-	0x73, 0x65, 0x72, 0x49, 0x64, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, 0x52, 0x6f,
-	0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a,
-	0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12,
-	0x33, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77,
-	0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76,
-	0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f,
-	0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45,
-	0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
-	0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, 0x76, 0x61,
-	0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, 0x75, 0x70,
-	0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
-	0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b,
-	0x65, 0x79, 0x12, 0x2c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55,
-	0x73, 0x65, 0x72, 0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
-	0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5f, 0x0a, 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46,
-	0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
-	0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
-	0x79, 0x12, 0x2e, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
-	0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65,
-	0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
-	0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, 0x08, 0x1a, 0x10, 0x33, 0x22, 0x87, 0x03, 0x0a, 0x0a,
-	0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x65,
-	0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
-	0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x41,
-	0x0a, 0x0d, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18,
-	0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e,
-	0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72,
-	0x73, 0x12, 0x3f, 0x0a, 0x0e, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75,
-	0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52,
-	0x75, 0x6c, 0x65, 0x52, 0x0d, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c,
-	0x65, 0x73, 0x12, 0x29, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03,
-	0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4f, 0x0a,
-	0x14, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x5f, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f,
-	0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61,
-	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69,
-	0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, 0x75, 0x74,
-	0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x45,
-	0x0a, 0x10, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c,
-	0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67,
-	0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67,
-	0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e,
-	0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74,
-	0x12, 0x41, 0x0a, 0x1d, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65,
-	0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65,
-	0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x70, 0x65, 0x65, 0x72, 0x4c, 0x6f, 0x67,
-	0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62,
-	0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69,
-	0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x73, 0x18,
-	0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x70, 0x65, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
-	0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x73, 0x22, 0x95, 0x01, 0x0a,
-	0x0e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12,
-	0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12,
-	0x19, 0x0a, 0x08, 0x6e, 0x65, 0x74, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x43, 0x69, 0x64, 0x72, 0x12, 0x1e, 0x0a, 0x0b, 0x6e, 0x65,
-	0x74, 0x5f, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x09, 0x6e, 0x65, 0x74, 0x56, 0x36, 0x43, 0x69, 0x64, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e,
-	0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06,
-	0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x73, 0x65,
-	0x72, 0x69, 0x61, 0x6c, 0x22, 0x21, 0x0a, 0x19, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d,
-	0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74,
-	0x61, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x65, 0x22, 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,
+	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,
+	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, 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, 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,
+	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, 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,
+	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,
-	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, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74,
-	0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
-	0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
-	0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08,
-	0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
-	0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
-	0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c,
-	0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
-	0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
-	0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12,
-	0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
-	0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e,
-	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79,
-	0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30,
-	0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53,
-	0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+	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, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78,
-	0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
-	0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
-	0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73,
-	0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
-	0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a,
-	0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
-	0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12,
-	0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e,
+	0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
+	0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
+	0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c,
+	0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a,
+	0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72,
+	0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
+	0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
+	0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d,
+	0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69,
+	0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46,
+	0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
+	0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
+	0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
+	0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68,
+	0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e,
 	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79,
 	0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61,
 	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
-	0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f,
-	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53,
+	0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
+	0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+	0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f,
+	0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
+	0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+	0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
+	0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
+	0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01,
+	0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65,
+	0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+	0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
+	0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
+	0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70,
+	0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
+	0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
+	0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
+	0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
+	0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
+	0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
+	0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4a,
+	0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
+	0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
+	0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto
index 24ff5bf37..acb6a3d95 100644
--- a/shared/management/proto/management.proto
+++ b/shared/management/proto/management.proto
@@ -114,6 +114,9 @@ message BundleParameters {
   // (or empty) keeps internal IP ranges, "strict" also anonymizes them.
   // Unknown values are treated as "strict".
   string anonymize_level  = 5;
+  // upload_url is the service URL the client requests an upload URL from
+  // before uploading the bundle. Empty selects the default upload server.
+  string upload_url       = 6;
 }
 
 message BundleResult {

From c170905bc9552c643a4815afa1e5b5eb6e3ecb94 Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Tue, 1 Sep 2026 12:19:16 +0200
Subject: [PATCH 25/40] [client] Allow logging out of the active profile when
 profiles are disabled (#7360)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* [client] Allow logging out of the active profile when profiles are disabled

A profile-addressed logout was refused outright when the profiles feature is
disabled: handleProfileLogout ran validateProfileOperation, which returned
Unavailable ("profiles are disabled, you cannot use this feature without
profiles enabled") before looking at which profile was targeted.

The desktop UI always addresses logout by profile — both the profile menu and
the session-expiration dialog send the active profile's ID — so a client with
profiles disabled could not log out at all; only a plain `netbird logout`,
which takes the profile-less path, still worked. Logging out of the profile the
daemon is already running is a deregistration, not profile management, and with
profiles disabled there is a single profile anyway, so every profile-addressed
logout is by definition an active-profile logout.

Replace validateProfileOperation with validateProfileLogout, which skips the
profiles-disabled check when the target is the active profile and keeps gating
logout of any other profile. This mirrors switchProfileIfNeeded, which already
gates only the branch that actually manages profiles. The dropped
allowActiveProfile parameter was always true, leaving canRemoveProfile
unreachable, so both are removed.

* [client] Compare the username and propagate state errors on profile logout

Review follow-ups on the logout gate:

Propagate the GetActiveProfileState failure instead of discarding it. A failed
lookup made the target look non-active, so a caller with profiles disabled got
"profiles are disabled" in place of the real error.

Compare the username along with the ID when deciding whether the target is the
active profile, matching switchProfileIfNeeded. Legacy profile IDs are display
names, so two users can hold the same ID in their own profile directories, and
an ID-only match let one user's logout pass the gate against the other user's
active profile. The default profile is shared and carries no username, so it
keeps matching on the ID alone.

Re-read the active profile before the connection teardown rather than reusing
the pre-flight snapshot. Login switches profiles under guardedConfigMu, which
the logout path does not hold, so a login that landed while the deregistration
was in flight would otherwise lose its fresh connection to a stale flag.

* [client] Address review on the profile logout gate

Pass the username down to logoutFromProfile and reuse the running config only
when the target is the active profile for that username. On an ID-only match a
legacy profile ID shared between two users made the connected-client path
deregister the active peer while its connection stayed up, which the gate fix
alone did not cover.

Split the setup-key-less branch of Login into beginSSOLogin, with the
reuse-the-pending-flow decision in pendingOAuthFlowResponse. Login's cognitive
complexity drops from 37 to 21 (gocognit), clearing the SonarQube report on
this file with no behaviour change.

Point the test fixture at an https URL, since the profiles a gated logout must
not touch only need to be unreachable, not plaintext.
---
 client/server/logout_gate_test.go | 200 +++++++++++++++++++++++++++
 client/server/server.go           | 218 ++++++++++++++++++------------
 2 files changed, 335 insertions(+), 83 deletions(-)
 create mode 100644 client/server/logout_gate_test.go

diff --git a/client/server/logout_gate_test.go b/client/server/logout_gate_test.go
new file mode 100644
index 000000000..2d84d1b6a
--- /dev/null
+++ b/client/server/logout_gate_test.go
@@ -0,0 +1,200 @@
+package server
+
+import (
+	"context"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"google.golang.org/grpc/codes"
+	gstatus "google.golang.org/grpc/status"
+
+	"github.com/netbirdio/netbird/client/internal"
+	"github.com/netbirdio/netbird/client/internal/profilemanager"
+	"github.com/netbirdio/netbird/client/proto"
+)
+
+// unreachableManagementURL keeps a test that is expected to stop at a gate from
+// reaching the network if the gate ever regresses: the profiles a logout must
+// not touch point here, so a leak fails fast instead of contacting a real
+// management server.
+const unreachableManagementURL = "https://127.0.0.1:9"
+
+// enableSSHOnProfile rewrites the profile config at cfgPath with the SSH server
+// enabled. Deregistering an SSH-enabled profile is a privileged change, so an
+// unprivileged caller is refused by requirePrivilegeForDeregistration before any
+// management connection is attempted, which is what keeps these tests offline.
+func enableSSHOnProfile(t *testing.T, cfgPath string) {
+	t.Helper()
+	_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+		ConfigPath:       cfgPath,
+		ManagementURL:    "https://api.netbird.io:443",
+		ServerSSHAllowed: boolPtr(true),
+	})
+	require.NoError(t, err)
+}
+
+// Logging out of the profile the daemon is already running is a deregistration,
+// not profile management, so the profiles-disabled kill switch must not block
+// it. The desktop UI always addresses logout by profile (both the profile menu
+// and the session-expiration dialog), so gating it left users with
+// disableProfiles enforced unable to log out at all.
+func TestLogout_ActiveProfileAllowedWhenProfilesDisabled(t *testing.T) {
+	s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
+	s.rootCtx = internal.CtxInitState(context.Background())
+	enableSSHOnProfile(t, cfgPath)
+
+	s.profilesDisabled = true
+
+	_, err := s.Logout(userCtx(), &proto.LogoutRequest{
+		ProfileName: &activeProfile,
+		Username:    &username,
+	})
+
+	require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
+	require.Equal(t, codes.PermissionDenied, gstatus.Code(err),
+		"logout of the active profile must reach the deregistration path, not be refused as profile management: %v", err)
+	require.NotContains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
+}
+
+// A profile-addressed logout that targets some *other* profile does manage
+// profiles, so it stays gated: with profiles disabled the daemon must not
+// deregister a peer the user is not currently running.
+func TestLogout_OtherProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
+	s, _, _, username, _ := setupServerWithProfile(t)
+	s.rootCtx = internal.CtxInitState(context.Background())
+
+	other := "other-profile"
+	_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+		ConfigPath:    filepath.Join(profilemanager.DefaultConfigPathDir, other+".json"),
+		ManagementURL: unreachableManagementURL,
+	})
+	require.NoError(t, err)
+
+	s.profilesDisabled = true
+
+	_, err = s.Logout(userCtx(), &proto.LogoutRequest{
+		ProfileName: &other,
+		Username:    &username,
+	})
+
+	require.Error(t, err)
+	require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the profiles-disabled refusal, got %v", err)
+	require.Contains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
+}
+
+// A legacy profile ID is a display name, so two users can hold the same ID in
+// their own profile directories. Matching on the ID alone would let one user's
+// logout pass the gate against the other user's active profile, so the username
+// is part of the comparison.
+func TestLogout_ForeignUserProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
+	s, _, _, username, _ := setupServerWithProfile(t)
+	s.rootCtx = internal.CtxInitState(context.Background())
+
+	// A legacy-style profile whose ID is its filename stem, and an active state
+	// claiming that same ID for a different user.
+	shared := "shared-legacy-name"
+	_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+		ConfigPath:    filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
+		ManagementURL: unreachableManagementURL,
+	})
+	require.NoError(t, err)
+	require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
+		ID:       profilemanager.ID(shared),
+		Username: "someone-else",
+	}))
+
+	s.profilesDisabled = true
+
+	_, err = s.Logout(userCtx(), &proto.LogoutRequest{
+		ProfileName: &shared,
+		Username:    &username,
+	})
+
+	require.Error(t, err)
+	require.Equal(t, codes.Unavailable, gstatus.Code(err),
+		"another user's profile must not pass the gate on an ID match alone: %v", err)
+}
+
+// Deregistering a namesake profile must not go out with the running config.
+// logoutFromProfile reuses the connected client's config when the target is the
+// active profile, and on an ID-only match a shared legacy ID made it reuse it
+// for another user's profile, deregistering the active peer instead.
+func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) {
+	s, _, _, username, cfgPath := setupServerWithProfile(t)
+	s.rootCtx = internal.CtxInitState(context.Background())
+
+	// The running config has the SSH server enabled, so reusing it would be
+	// refused with PermissionDenied. The namesake profile does not, so the
+	// correct path gets as far as dialing its own unreachable management URL.
+	enableSSHOnProfile(t, cfgPath)
+	running, err := profilemanager.GetConfig(cfgPath)
+	require.NoError(t, err)
+	s.config = running
+	s.connectClient = newDummyConnectClient(context.Background())
+
+	shared := "shared-legacy-name"
+	_, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+		ConfigPath:    filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
+		ManagementURL: unreachableManagementURL,
+	})
+	require.NoError(t, err)
+	require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
+		ID:       profilemanager.ID(shared),
+		Username: "someone-else",
+	}))
+
+	// Bounded so the deregistration the fixed path attempts fails on the dial
+	// rather than sitting in gRPC backoff for the whole test timeout.
+	ctx, cancel := context.WithTimeout(userCtx(), 2*time.Second)
+	t.Cleanup(cancel)
+
+	_, err = s.Logout(ctx, &proto.LogoutRequest{
+		ProfileName: &shared,
+		Username:    &username,
+	})
+
+	require.Error(t, err)
+	require.NotEqual(t, codes.PermissionDenied, gstatus.Code(err),
+		"the namesake profile was deregistered with the running config: %v", err)
+}
+
+// The connection teardown follows the profile that is active when the logout
+// completes, not the one seen before it started: Login switches profiles under
+// guardedConfigMu, which the logout path does not hold, so a login that landed
+// meanwhile must keep its connection.
+func TestCleanupAfterProfileLogout_FollowsTheCurrentActiveProfile(t *testing.T) {
+	s, _, activeProfile, username, _ := setupServerWithProfile(t)
+	s.rootCtx = internal.CtxInitState(context.Background())
+
+	state := internal.CtxGetState(s.rootCtx)
+
+	s.cleanupAfterProfileLogout("some-other-profile", username)
+	status, err := state.Status()
+	require.NoError(t, err)
+	require.NotEqual(t, internal.StatusNeedsLogin, status,
+		"logging out of a profile that is not active must not ask for a new login")
+
+	s.cleanupAfterProfileLogout(profilemanager.ID(activeProfile), username)
+	status, err = state.Status()
+	require.NoError(t, err)
+	require.Equal(t, internal.StatusNeedsLogin, status,
+		"logging out of the active profile must ask for a new login")
+}
+
+// With profiles enabled the gate is out of the way on both surfaces; the active
+// profile still reaches the deregistration path.
+func TestLogout_ActiveProfileAllowedWhenProfilesEnabled(t *testing.T) {
+	s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
+	s.rootCtx = internal.CtxInitState(context.Background())
+	enableSSHOnProfile(t, cfgPath)
+
+	_, err := s.Logout(userCtx(), &proto.LogoutRequest{
+		ProfileName: &activeProfile,
+		Username:    &username,
+	})
+
+	require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
+	require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want the privilege refusal, got %v", err)
+}
diff --git a/client/server/server.go b/client/server/server.go
index 23dccc9b1..b066e9719 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -710,54 +710,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
 	}
 
 	if msg.SetupKey == "" {
-		hint := ""
-		if msg.Hint != nil {
-			hint = *msg.Hint
-		}
-		oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
-		if err != nil {
-			state.Set(internal.StatusLoginFailed)
-			return nil, err
-		}
-
-		if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
-			if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
-				log.Debugf("using previous oauth flow info")
-				state.Set(internal.StatusNeedsLogin)
-				return &proto.LoginResponse{
-					NeedsSSOLogin:           true,
-					VerificationURI:         s.oauthAuthFlow.info.VerificationURI,
-					VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
-					UserCode:                s.oauthAuthFlow.info.UserCode,
-				}, nil
-			} else {
-				log.Warnf("canceling previous waiting execution")
-				if s.oauthAuthFlow.waitCancel != nil {
-					s.oauthAuthFlow.waitCancel()
-				}
-			}
-		}
-
-		authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
-		if err != nil {
-			log.Errorf("getting a request OAuth flow failed: %v", err)
-			return nil, err
-		}
-
-		s.mutex.Lock()
-		s.oauthAuthFlow.flow = oAuthFlow
-		s.oauthAuthFlow.info = authInfo
-		s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
-		s.mutex.Unlock()
-
-		state.Set(internal.StatusNeedsLogin)
-
-		return &proto.LoginResponse{
-			NeedsSSOLogin:           true,
-			VerificationURI:         authInfo.VerificationURI,
-			VerificationURIComplete: authInfo.VerificationURIComplete,
-			UserCode:                authInfo.UserCode,
-		}, nil
+		return s.beginSSOLogin(ctx, config, msg)
 	}
 
 	// Setup-key path: we are about to dial Management with the key, so the
@@ -773,6 +726,76 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
 	return &proto.LoginResponse{}, nil
 }
 
+// beginSSOLogin starts the browser leg of a login that carries no setup key and
+// returns the response that parks the caller on it.
+func (s *Server) beginSSOLogin(ctx context.Context, config *profilemanager.Config, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
+	state := internal.CtxGetState(s.rootCtx)
+
+	hint := ""
+	if msg.Hint != nil {
+		hint = *msg.Hint
+	}
+	oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
+	if err != nil {
+		state.Set(internal.StatusLoginFailed)
+		return nil, err
+	}
+
+	if resp := s.pendingOAuthFlowResponse(ctx, oAuthFlow); resp != nil {
+		state.Set(internal.StatusNeedsLogin)
+		return resp, nil
+	}
+
+	authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
+	if err != nil {
+		log.Errorf("getting a request OAuth flow failed: %v", err)
+		return nil, err
+	}
+
+	s.mutex.Lock()
+	s.oauthAuthFlow.flow = oAuthFlow
+	s.oauthAuthFlow.info = authInfo
+	s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
+	s.mutex.Unlock()
+
+	state.Set(internal.StatusNeedsLogin)
+
+	return &proto.LoginResponse{
+		NeedsSSOLogin:           true,
+		VerificationURI:         authInfo.VerificationURI,
+		VerificationURIComplete: authInfo.VerificationURIComplete,
+		UserCode:                authInfo.UserCode,
+	}, nil
+}
+
+// pendingOAuthFlowResponse returns the in-flight flow's response when it
+// targets the same IdP client and has enough time left for the user to finish
+// the browser leg, so a second login joins the pending flow instead of opening
+// a competing one. A flow too close to expiry has its waiter cancelled and nil
+// returned, leaving the caller to start a fresh flow.
+func (s *Server) pendingOAuthFlowResponse(ctx context.Context, oAuthFlow auth.OAuthFlow) *proto.LoginResponse {
+	if s.oauthAuthFlow.flow == nil || s.oauthAuthFlow.flow.GetClientID(ctx) != oAuthFlow.GetClientID(ctx) {
+		return nil
+	}
+
+	if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
+		log.Debugf("using previous oauth flow info")
+		return &proto.LoginResponse{
+			NeedsSSOLogin:           true,
+			VerificationURI:         s.oauthAuthFlow.info.VerificationURI,
+			VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
+			UserCode:                s.oauthAuthFlow.info.UserCode,
+		}
+	}
+
+	log.Warnf("canceling previous waiting execution")
+	if s.oauthAuthFlow.waitCancel != nil {
+		s.oauthAuthFlow.waitCancel()
+	}
+
+	return nil
+}
+
 // WaitSSOLogin validates the supplied userCode against the in-flight OAuth
 // device/PKCE flow and blocks until the user finishes the browser leg.
 //
@@ -1347,11 +1370,16 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
 		return nil, err
 	}
 
-	if err := s.validateProfileOperation(resolved.ID, true); err != nil {
+	activeProf, err := s.profileManager.GetActiveProfileState()
+	if err != nil {
+		return nil, gstatus.Errorf(codes.FailedPrecondition, "failed to get active profile state: %v", err)
+	}
+
+	if err := s.validateProfileLogout(resolved.ID, isActiveProfile(activeProf, resolved.ID, username)); err != nil {
 		return nil, err
 	}
 
-	if err := s.logoutFromProfile(ctx, resolved); err != nil {
+	if err := s.logoutFromProfile(ctx, resolved, username); err != nil {
 		log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
 		// A refused deregistration is already a status error carrying the reason
 		// and the command to run; rewrapping it as Internal would flatten both
@@ -1362,18 +1390,35 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
 		return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
 	}
 
-	activeProf, _ := s.profileManager.GetActiveProfileState()
-	if activeProf != nil && activeProf.ID == resolved.ID {
-		if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
-			log.Errorf("failed to cleanup connection: %v", err)
-		}
-		state := internal.CtxGetState(s.rootCtx)
-		state.Set(internal.StatusNeedsLogin)
-	}
+	s.cleanupAfterProfileLogout(resolved.ID, username)
 
 	return &proto.LogoutResponse{}, nil
 }
 
+// cleanupAfterProfileLogout tears the connection down and asks for a new login
+// when the profile that was just deregistered is the one the daemon is running.
+// The active profile is read again here rather than reused from the pre-flight
+// check: Login switches profiles under guardedConfigMu, which this path does not
+// hold, so a login that landed meanwhile must not have its fresh connection
+// dropped by a logout that targeted the profile it replaced.
+func (s *Server) cleanupAfterProfileLogout(id profilemanager.ID, username string) {
+	activeProf, err := s.profileManager.GetActiveProfileState()
+	if err != nil {
+		log.Errorf("failed to get active profile state after logout from profile %s: %v", id, err)
+		return
+	}
+
+	if !isActiveProfile(activeProf, id, username) {
+		return
+	}
+
+	if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
+		log.Errorf("failed to cleanup connection: %v", err)
+	}
+	state := internal.CtxGetState(s.rootCtx)
+	state.Set(internal.StatusNeedsLogin)
+}
+
 func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutResponse, error) {
 	if s.config == nil {
 		activeProf, err := s.profileManager.GetActiveProfileState()
@@ -1425,40 +1470,47 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof
 	return config, configExisted, nil
 }
 
-func (s *Server) canRemoveProfile(id profilemanager.ID) error {
-	if id == profilemanager.DefaultProfileName {
-		return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName)
-	}
-
-	activeProf, err := s.profileManager.GetActiveProfileState()
-	if err == nil && activeProf.ID == id {
-		return fmt.Errorf("remove active profile: %s", id)
-	}
-
-	return nil
-}
-
-func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error {
-	if s.checkProfilesDisabled() {
-		return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
-	}
-
+// validateProfileLogout gates a profile-addressed logout. Deregistering the
+// profile the daemon already runs is what a plain `netbird logout` does, so the
+// profiles-disabled kill switch must not block it. Logging out of any other
+// profile is profile management and stays gated.
+func (s *Server) validateProfileLogout(id profilemanager.ID, isActive bool) error {
 	if id == "" {
 		return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
 	}
 
-	if !allowActiveProfile {
-		if err := s.canRemoveProfile(id); err != nil {
-			return gstatus.Errorf(codes.InvalidArgument, "%v", err)
-		}
+	if isActive {
+		return nil
+	}
+
+	if s.checkProfilesDisabled() {
+		return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
 	}
 
 	return nil
 }
 
-func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error {
+// isActiveProfile reports whether id is the profile the daemon runs for
+// username. The username is part of the comparison because legacy profile IDs
+// are display names, which two users can both hold; the default profile is
+// shared by every user and carries no username.
+func isActiveProfile(activeProf *profilemanager.ActiveProfileState, id profilemanager.ID, username string) bool {
+	if activeProf == nil || activeProf.ID != id {
+		return false
+	}
+
+	return id == profilemanager.DefaultProfileName || activeProf.Username == username
+}
+
+// logoutFromProfile deregisters profile, reusing the running config when
+// profile is the one the daemon is connected with. The username takes part in
+// that decision for the same reason it does in the logout gate: a legacy
+// profile ID is a display name two users can share, and sending the running
+// config for a namesake would deregister the active peer instead of the
+// requested one.
+func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile, username string) error {
 	activeProf, err := s.profileManager.GetActiveProfileState()
-	if err == nil && activeProf.ID == profile.ID && s.connectClient != nil {
+	if err == nil && isActiveProfile(activeProf, profile.ID, username) && s.connectClient != nil {
 		return s.sendLogoutRequest(ctx)
 	}
 
@@ -2227,7 +2279,7 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
 		return nil, err
 	}
 
-	if err := s.logoutFromProfile(ctx, resolved); err != nil {
+	if err := s.logoutFromProfile(ctx, resolved, msg.Username); err != nil {
 		// Deregistration is best-effort here: the local profile is removed
 		// either way, so an unprivileged caller leaves the peer registered on
 		// the management server rather than being blocked from removing it.

From 922be0b8c296f7f84f3dfd5c64eb53bfaeebb2c1 Mon Sep 17 00:00:00 2001
From: Anton Groshev 
Date: Tue, 1 Sep 2026 15:38:06 +0500
Subject: [PATCH 26/40] Fix docs link (#7352)

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 3bcb4a035..336332043 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@
   
     Start using NetBird at netbird.io
     
- See Documentation + See Documentation
Join our Slack channel or our Community forum
From 352a1d348aa03e4f2277d801c31ca133f1e21c88 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:41:12 +0200 Subject: [PATCH 27/40] [client] Do not log the WireGuard key on a parse failure (#7379) The error already says what went wrong: an invalid base64 payload reports the offending byte offset, and a wrong key size reports the length. Passing the key itself adds nothing an operator can act on, and the line is emitted at Error level, so it reaches every log sink and every debug bundle. --- client/internal/connect.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/internal/connect.go b/client/internal/connect.go index ca50f912f..08bd84f0c 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -242,7 +242,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan wrapErr := state.Wrap myPrivateKey, err := wgtypes.ParseKey(c.config.PrivateKey) if err != nil { - log.Errorf("failed parsing Wireguard key %s: [%s]", c.config.PrivateKey, err.Error()) + log.Errorf("failed parsing Wireguard key: %s", err) return wrapErr(err) } From 4749005a502abf3c58a287242f328d29ee561cab Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 1 Sep 2026 12:50:03 +0200 Subject: [PATCH 28/40] [client] Resolve profiles for the sudo invoking user instead of root (#7238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Resolve profiles for the sudo invoking user instead of root The SSH server flags force `netbird up` through sudo, but the CLI resolved every per-user path with the process user. As root that reads root's own (empty) local state, so a `sudo netbird up` silently switched the daemon from the user's profile to the default one — cancelling any login already waiting in the browser — and then ran an SSO login for the default profile's config. Whichever account that login returned, the default profile's peer belongs to someone else, so every attempt ended in "peer is already registered by a different User or a Setup Key", with nothing telling the user why. Resolve the acting user through SUDO_USER when running as root: the active profile, the profile config paths and the stored account email now come from the invoking user's directories. Privilege decisions are untouched — they stay on the kernel credentials of the daemon connection, which an environment variable can never influence; a forged SUDO_USER only selects a profile root could select anyway. The invoking user's directories are strictly read-only under sudo. Anything root wrote there would be root-owned and break the user's own runs, so instead of chowning files back, the local writes are skipped: the active-profile bookkeeping and the account-email state simply do not update from a sudo run (the daemon records the switch on its side; a skipped email write costs at most one extra account prompt later). Plain root — no sudo context — has no user to act for, so the ambiguity is refused instead of guessed at: when the daemon's active profile differs from what root resolves and no --profile was given, up fails with a message naming both profiles, instead of silently switching the daemon and failing later with the ownership error. * [client] Act on the daemon-resolved profile and fail closed in the root guard Under sudo the local active-profile mirror is not updated, so up/login re-reading it after a profile switch acted on the previous profile; use the daemon-resolved ID directly instead. The plain-root guard now runs after the readiness wait, denies on lookup errors and empty responses, and matches the owning username as well; an unowned profile (fresh install) and a daemon predating the RPC stay allowed. Write-skip decisions key off the sudo environment alone so a transient user lookup failure cannot turn a run into writing root-owned files into the user's directory, and RemoveProfileState honors the read-only rule too. * [client] Return a wrapped error instead of double-reporting the dial failure * [client] Read the profile from the daemon when the local mirror is not authoritative Under sudo without --profile, `up` took the active profile from the invoking user's local active_profile.txt mirror and drove the daemon to it. But that mirror is never written under sudo (the SwitchProfile write is a no-op), so it goes stale after any --profile run and silently switches the daemon back to the mirror's default. The plain-root guard was meant to refuse exactly this ambiguity but only ran for plain root, never for the sudo case the fix targets. When there is no --profile and the mirror is not authoritative (sudo or plain root), take the profile the daemon already holds for the invoking user instead of the stale mirror: stay on the user's current profile when the daemon owns it (or it is unowned, as on a fresh install), and refuse with a --profile hint when the daemon is on another user's profile. A daemon predating the RPC keeps the mirror-derived profile. Reproduce (before this change): 1. As a non-root user misha, with the daemon installed and running: sudo netbird up --profile work misha connects on the `work` profile. 2. Because the local mirror write is skipped under sudo, ~misha/.config/netbird/active_profile.txt still says `default` (or is still absent, which also resolves to `default`). 3. Run a bare: sudo netbird up The CLI reads `default` from the frozen mirror and sends ProfileName=default; the daemon silently switches away from `work` and brings the tunnel up on `default` — a different account/peer than the one last chosen, with no warning. After this change step 3 stays on `work`. * [client] Return a sentinel error instead of nil-nil for the missing daemon RPC * [client] Load the extend-session hint from the resolved profile * [client] Fail closed instead of reading root's config when the sudo user lookup fails * [client] Fail closed in InvokingUser when the sudo user lookup fails A previous change made baseConfigDir fail closed when SUDO_USER cannot be resolved, but InvokingUser still fell through to user.Current(). Those two guards disagreed: the active-profile mirror and the email state refused to read root's directory, while every profile-path caller happily resolved as root. The consequence of a transient NSS failure under sudo was that Profile.FilePath resolved through getConfigDirForUser("root"), creating /var/lib/netbird/root and reading the profile JSON from there, and the CLI sent Username "root" to the daemon in SetConfig and ListProfiles, so the daemon resolved the same phantom namespace. The invoking user was silently moved onto a root-owned profile instead of being told the lookup failed. Fail closed at the single source of the fallback. getConfigDirForUser is left alone on purpose: it is a pure path helper that also serves daemon-supplied usernames, and under sudo with a successful lookup it must still create the invoking user's own profile directory. --- client/cmd/debug.go | 3 +- client/cmd/login.go | 34 ++- client/cmd/logout.go | 4 +- client/cmd/profile.go | 11 +- client/cmd/up.go | 90 +++++-- client/cmd/up_test.go | 88 +++++++ client/internal/profilemanager/config.go | 16 ++ .../internal/profilemanager/invoking_user.go | 100 ++++++++ .../profilemanager/invoking_user_test.go | 230 ++++++++++++++++++ .../internal/profilemanager/profilemanager.go | 12 +- client/internal/profilemanager/state.go | 16 ++ 11 files changed, 557 insertions(+), 47 deletions(-) create mode 100644 client/cmd/up_test.go create mode 100644 client/internal/profilemanager/invoking_user.go create mode 100644 client/internal/profilemanager/invoking_user_test.go diff --git a/client/cmd/debug.go b/client/cmd/debug.go index 893b1e248..98fe53626 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -3,7 +3,6 @@ package cmd import ( "context" "fmt" - "os/user" "strings" "time" @@ -114,7 +113,7 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("get active profile: %v", err) } - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } diff --git a/client/cmd/login.go b/client/cmd/login.go index 6aa019896..f703b32c4 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "os/user" "strings" log "github.com/sirupsen/logrus" @@ -53,7 +52,7 @@ var loginCmd = &cobra.Command{ // nolint ctx = context.WithValue(ctx, system.DeviceNameCtxKey, hostName) } - username, err := user.Current() + username, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } @@ -74,7 +73,7 @@ var loginCmd = &cobra.Command{ if providedSetupKey != "" { return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers") } - if err := doExtendSession(ctx, cmd); err != nil { + if err := doExtendSession(ctx, cmd, activeProf); err != nil { return fmt.Errorf("extend session failed: %v", err) } return nil @@ -176,7 +175,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str // (browser + verification URL) and the resulting JWT is forwarded to the // management server's ExtendAuthSession RPC. The tunnel stays up // throughout — no Down/Up, no network-map resync. -func doExtendSession(ctx context.Context, cmd *cobra.Command) error { +func doExtendSession(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { //nolint @@ -190,14 +189,12 @@ func doExtendSession(ctx context.Context, cmd *cobra.Command) error { // the CLI runs in the user's session, the daemon does not: tell it what we can see req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()} - // Pre-fill the IdP login hint from the active profile so the user + // Pre-fill the IdP login hint from the resolved profile so the user // doesn't have to retype their email. Best-effort: we still proceed // without a hint if the lookup fails. pm := profilemanager.NewProfileManager() - if active, perr := pm.GetActiveProfile(); perr == nil { - if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" { - req.Hint = &profState.Email - } + if profState, perr := pm.GetProfileState(activeProf.ID); perr == nil && profState.Email != "" { + req.Hint = &profState.Email } startResp, err := client.RequestExtendAuthSession(ctx, req) @@ -235,9 +232,11 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr // switch profile if provided if profileName != "" { - if err := switchProfileOnDaemon(ctx, pm, profileName, username); err != nil { + prof, err := switchProfileOnDaemon(ctx, pm, profileName, username) + if err != nil { return nil, fmt.Errorf("switch profile: %v", err) } + return prof, nil } activeProf, err := pm.GetActiveProfile() @@ -251,20 +250,19 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr return activeProf, nil } -func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error { +func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) (*profilemanager.Profile, error) { resolvedID, err := switchProfile(ctx, handle, username) if err != nil { - return fmt.Errorf("switch profile on daemon: %v", err) + return nil, fmt.Errorf("switch profile on daemon: %v", err) } if err := pm.SwitchProfile(resolvedID); err != nil { - return fmt.Errorf("switch profile: %v", err) + return nil, fmt.Errorf("switch profile: %v", err) } conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - log.Errorf("failed to connect to service CLI interface %v", err) - return err + return nil, fmt.Errorf("connect to service CLI interface: %w", err) } defer conn.Close() @@ -272,17 +270,17 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage status, err := client.Status(ctx, &proto.StatusRequest{}) if err != nil { - return fmt.Errorf("unable to get daemon status: %v", err) + return nil, fmt.Errorf("unable to get daemon status: %v", err) } if status.Status == string(internal.StatusConnected) { if _, err := client.Down(ctx, &proto.DownRequest{}); err != nil { log.Errorf("call service down method: %v", err) - return err + return nil, err } } - return nil + return &profilemanager.Profile{ID: resolvedID}, nil } // switchProfile asks the daemon to switch to the profile identified by diff --git a/client/cmd/logout.go b/client/cmd/logout.go index dcd7b5075..cf2a4e446 100644 --- a/client/cmd/logout.go +++ b/client/cmd/logout.go @@ -3,11 +3,11 @@ package cmd import ( "context" "fmt" - "os/user" "time" "github.com/spf13/cobra" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" ) @@ -37,7 +37,7 @@ var logoutCmd = &cobra.Command{ if profileName != "" { req.ProfileName = &profileName - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } diff --git a/client/cmd/profile.go b/client/cmd/profile.go index 268034e70..2d6653537 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os/user" "strings" "text/tabwriter" "time" @@ -97,7 +96,7 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error { } defer conn.Close() - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } @@ -138,7 +137,7 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { return err } - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } @@ -179,7 +178,7 @@ func renameProfileFunc(cmd *cobra.Command, args []string) error { } defer conn.Close() - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } @@ -233,7 +232,7 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error { } defer conn.Close() - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } @@ -261,7 +260,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { profileManager := profilemanager.NewProfileManager() handle := args[0] - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } diff --git a/client/cmd/up.go b/client/cmd/up.go index 5bc41a964..9cf5eea26 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -2,10 +2,10 @@ package cmd import ( "context" + "errors" "fmt" "net" "net/netip" - "os/user" "runtime" "strings" "time" @@ -48,6 +48,8 @@ const ( profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used." ) +var errDaemonActiveProfileUnsupported = errors.New("daemon does not support active profile lookup") + var ( foregroundMode bool dnsLabels []string @@ -122,23 +124,25 @@ func upFunc(cmd *cobra.Command, args []string) error { pm := profilemanager.NewProfileManager() - username, err := user.Current() + username, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } + var activeProf *profilemanager.Profile var profileSwitched bool // switch profile if provided if profileName != "" { - if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil { + activeProf, err = switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username) + if err != nil { return fmt.Errorf("switch profile: %v", err) } profileSwitched = true - } - - activeProf, err := pm.GetActiveProfile() - if err != nil { - return fmt.Errorf("get active profile: %v", err) + } else { + activeProf, err = pm.GetActiveProfile() + if err != nil { + return fmt.Errorf("get active profile: %v", err) + } } if foregroundMode { @@ -150,13 +154,15 @@ func upFunc(cmd *cobra.Command, args []string) error { // switchOrCreateProfile switches the active profile to the one identified by // handle, creating it first when it does not exist yet. This restores the // pre-0.73 behaviour where `netbird up --profile ` auto-creates a -// missing profile instead of failing. -func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error { +// missing profile instead of failing. Returns the daemon-resolved profile so +// callers act on it directly instead of re-reading the local state, which is +// not updated under sudo. +func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) (*profilemanager.Profile, error) { resolvedID, err := switchProfile(ctx, handle, username) if err != nil { st, ok := gstatus.FromError(err) if !ok || st.Code() != codes.NotFound { - return err + return nil, err } // Don't fail immediately on a create error: a concurrent run may // have created the profile between the NotFound above and this @@ -165,16 +171,16 @@ func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManage _, createErr := createProfile(ctx, handle, username) if resolvedID, err = switchProfile(ctx, handle, username); err != nil { if createErr != nil { - return fmt.Errorf("create profile: %w", createErr) + return nil, fmt.Errorf("create profile: %w", createErr) } - return err + return nil, err } } if err := pm.SwitchProfile(resolvedID); err != nil { - return err + return nil, err } - return nil + return &profilemanager.Profile{ID: resolvedID}, nil } // createProfile dials the daemon and creates a new profile with the given @@ -302,6 +308,30 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager return fmt.Errorf("unable to get daemon status: %v", err) } + // Under sudo the invoking user's local active-profile mirror is never + // written (the SwitchProfile write is a no-op), and plain root has no + // invoking user at all — so the mirror read into activeProf above is stale + // or defaulted and must not drive the daemon. With no --profile to make the + // choice explicit, take the profile the daemon already holds for this user + // instead: it stays on the user's current profile rather than silently + // switching to the mirror's default, and refuses when the daemon is on + // another user's profile. + if profileName == "" && !profilemanager.MirrorIsAuthoritative() { + u, err := profilemanager.InvokingUser() + if err != nil { + return fmt.Errorf("get current user: %v", err) + } + resolved, err := daemonActiveProfileForUser(ctx, client, u.Username) + switch { + case errors.Is(err, errDaemonActiveProfileUnsupported): + log.Warnf("keeping the locally resolved profile: %v", err) + case err != nil: + return err + default: + activeProf = resolved + } + } + if status.Status == string(internal.StatusConnected) { if !profileSwitched { cmd.Println("Already connected") @@ -314,7 +344,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager } } - username, err := user.Current() + username, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } @@ -881,3 +911,31 @@ func isValidAddrPort(input string) bool { _, err := netip.ParseAddrPort(input) return err == nil } + +// daemonActiveProfileForUser returns the profile the daemon currently holds for +// username, for the no --profile case where the local mirror is not +// authoritative (sudo or plain root). It returns that profile when the daemon +// owns it for this user or when the profile is unowned (empty username, as on a +// fresh install), so the caller acts on the daemon's real state instead of the +// stale mirror. It denies with a --profile hint when the daemon is on another +// user's profile, when the lookup fails, or when the daemon reports no active +// profile. Returns errDaemonActiveProfileUnsupported when the daemon predates +// the RPC; the caller keeps the mirror-derived profile in that case. +func daemonActiveProfileForUser(ctx context.Context, client proto.DaemonServiceClient, username string) (*profilemanager.Profile, error) { + active, err := client.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unimplemented { + return nil, fmt.Errorf("%w: %v", errDaemonActiveProfileUnsupported, err) + } + return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon's active profile could not be verified: %v", err) + } + if active.GetId() == "" { + return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon reported no active profile") + } + if active.GetUsername() != "" && active.GetUsername() != username { + return nil, fmt.Errorf( + "pass --profile to choose the profile explicitly: the daemon's active profile is %q (user %q) but this invocation runs for %q", + active.GetProfileName(), active.GetUsername(), username) + } + return &profilemanager.Profile{ID: profilemanager.ID(active.GetId())}, nil +} diff --git a/client/cmd/up_test.go b/client/cmd/up_test.go new file mode 100644 index 000000000..9b5f9fbea --- /dev/null +++ b/client/cmd/up_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +type fakeActiveProfileClient struct { + proto.DaemonServiceClient + resp *proto.GetActiveProfileResponse + err error +} + +func (f *fakeActiveProfileClient) GetActiveProfile(_ context.Context, _ *proto.GetActiveProfileRequest, _ ...grpc.CallOption) (*proto.GetActiveProfileResponse, error) { + return f.resp, f.err +} + +func TestDaemonActiveProfileForUserReturnsOwnProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "root"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.NoError(t, err) + require.NotNil(t, prof) + assert.Equal(t, profilemanager.ID("default"), prof.ID) +} + +func TestDaemonActiveProfileForUserReturnsUnownedProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: ""}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.NoError(t, err) + require.NotNil(t, prof) + assert.Equal(t, profilemanager.ID("default"), prof.ID) +} + +func TestDaemonActiveProfileForUserKeepsDaemonProfileOverStaleMirror(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "misha") + require.NoError(t, err) + require.NotNil(t, prof) + assert.Equal(t, profilemanager.ID("ab12"), prof.ID) +} + +func TestDaemonActiveProfileForUserRejectsOtherUsersProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserRejectsOtherUsersDefaultProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "misha"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserRejectsLookupError(t *testing.T) { + client := &fakeActiveProfileClient{err: gstatus.Error(codes.Internal, "boom")} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserRejectsEmptyResponse(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserKeepsMirrorWhenDaemonWithoutRPC(t *testing.T) { + client := &fakeActiveProfileClient{err: gstatus.Error(codes.Unimplemented, "unknown method")} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.ErrorIs(t, err, errDaemonActiveProfileUnsupported) + assert.Nil(t, prof) +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index eacc6fd5f..e83cb4015 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -225,6 +225,12 @@ func getConfigDir() (string, error) { } configDir := filepath.Join(base, "netbird") + // Under sudo this is the invoking user's directory and strictly read-only: + // anything root creates in it would be root-owned and break the user's own + // runs. Reads of a missing directory fall through to defaults. + if sudoActive() { + return configDir, nil + } if err := os.MkdirAll(configDir, 0o755); err != nil { return "", err } @@ -232,6 +238,16 @@ func getConfigDir() (string, error) { } func baseConfigDir() (string, error) { + if u, ok := sudoInvokingUser(); ok { + return userBaseConfigDir(u) + } + // Fail closed instead of falling through to root's own config directory: + // reading root's active-profile and email state for what is actually the + // invoking user's invocation is the very confusion this resolution exists + // to prevent. + if sudoActive() { + return "", fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root's config directory", os.Getenv(envSudoUser)) + } if runtime.GOOS == "darwin" { if u, err := user.Current(); err == nil && u.HomeDir != "" { return filepath.Join(u.HomeDir, "Library", "Application Support"), nil diff --git a/client/internal/profilemanager/invoking_user.go b/client/internal/profilemanager/invoking_user.go new file mode 100644 index 000000000..c86a6ce43 --- /dev/null +++ b/client/internal/profilemanager/invoking_user.go @@ -0,0 +1,100 @@ +package profilemanager + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "runtime" + + log "github.com/sirupsen/logrus" +) + +const envSudoUser = "SUDO_USER" + +var ( + geteuid = os.Geteuid + lookupUser = user.Lookup +) + +// InvokingUser returns the user a CLI invocation acts for. Under sudo that is +// the user who ran sudo, not root: privileged flags force commands through +// sudo, and resolving profiles as root would silently switch the daemon to +// root's (default) profile instead of the invoking user's. Privilege decisions +// are not made here — those stay on the kernel credentials of the daemon +// connection, which SUDO_USER (a plain environment variable) can never +// influence; a forged value only selects a profile root could select anyway. +func InvokingUser() (*user.User, error) { + if u, ok := sudoInvokingUser(); ok { + return u, nil + } + // Fail closed instead of falling through to root: every caller feeds this + // username into profile-path resolution, so a lookup failure would resolve + // (and create) a root-owned profile namespace and switch the daemon onto it + // behind the invoking user's back. + if sudoActive() { + return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser)) + } + return user.Current() +} + +// IsPlainRoot reports that the process runs as root with no usable sudo +// context: there is no invoking user to act for, so per-user resolution falls +// back to root's own (empty) state. Callers use it to refuse ambiguous +// operations instead of silently acting on the wrong profile. +func IsPlainRoot() bool { + if geteuid() != 0 { + return false + } + _, ok := sudoInvokingUser() + return !ok +} + +// MirrorIsAuthoritative reports whether the invoking user's local +// active-profile mirror can be trusted as the profile selector. It cannot under +// sudo (writes to it are skipped, so it goes stale) or as plain root (there is +// no invoking user, so it falls back to root's own default). Callers use it to +// decide whether to read the profile from the mirror or from the daemon. +func MirrorIsAuthoritative() bool { + return !sudoActive() && !IsPlainRoot() +} + +// sudoInvokingUser resolves SUDO_USER when the process runs as root under +// sudo. Returns false whenever the sudo context is absent or unusable, in +// which case callers fall back to the process user. +func sudoInvokingUser() (*user.User, bool) { + if !sudoActive() { + return nil, false + } + name := os.Getenv(envSudoUser) + u, err := lookupUser(name) + if err != nil { + log.Warnf("sudo invoking user %q lookup: %v", name, err) + return nil, false + } + return u, true +} + +// sudoActive reports a sudo context from the environment alone: write-skip +// decisions key off it so a transient user lookup failure can never flip a +// run from read-only to writing root-owned files into the user's directory. +func sudoActive() bool { + if geteuid() != 0 { + return false + } + name := os.Getenv(envSudoUser) + return name != "" && name != "root" +} + +// userBaseConfigDir mirrors os.UserConfigDir for a user other than the process +// owner. Environment overrides (XDG_CONFIG_HOME) cannot be honoured here: under +// sudo the environment is root's, not the invoking user's. +func userBaseConfigDir(u *user.User) (string, error) { + if u.HomeDir == "" { + return "", fmt.Errorf("user %s has no home directory", u.Username) + } + if runtime.GOOS == "darwin" { + return filepath.Join(u.HomeDir, "Library", "Application Support"), nil + } + return filepath.Join(u.HomeDir, ".config"), nil +} diff --git a/client/internal/profilemanager/invoking_user_test.go b/client/internal/profilemanager/invoking_user_test.go new file mode 100644 index 000000000..54c8ad8fd --- /dev/null +++ b/client/internal/profilemanager/invoking_user_test.go @@ -0,0 +1,230 @@ +package profilemanager + +import ( + "errors" + "io/fs" + "os" + "os/user" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvokingUserFallsBackToProcessUser(t *testing.T) { + t.Setenv(envSudoUser, "") + + got, err := InvokingUser() + require.NoError(t, err) + + current, err := user.Current() + require.NoError(t, err) + assert.Equal(t, current.Username, got.Username) +} + +func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) { + t.Setenv(envSudoUser, "") + _, ok := sudoInvokingUser() + assert.False(t, ok) +} + +func TestSudoInvokingUserIgnoresRoot(t *testing.T) { + t.Setenv(envSudoUser, "root") + origEuid := geteuid + geteuid = func() int { return 0 } + t.Cleanup(func() { geteuid = origEuid }) + + _, ok := sudoInvokingUser() + assert.False(t, ok, "sudo from a root shell must not redirect anything") + assert.False(t, sudoActive()) + assert.True(t, IsPlainRoot()) +} + +func TestSudoInvokingUserResolvesInvokingUser(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + + u, ok := sudoInvokingUser() + require.True(t, ok) + assert.Equal(t, "misha", u.Username) + + got, err := InvokingUser() + require.NoError(t, err) + assert.Equal(t, "misha", got.Username) + + assert.False(t, IsPlainRoot()) +} + +func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + got, err := InvokingUser() + require.Error(t, err) + assert.Nil(t, got, "must not resolve to the root process user") +} + +func TestProfileFilePathFailsClosedWhenSudoLookupFails(t *testing.T) { + profilesRoot := t.TempDir() + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + origDir := DefaultConfigPathDir + DefaultConfigPathDir = profilesRoot + t.Cleanup(func() { DefaultConfigPathDir = origDir }) + + p := &Profile{ID: "0123456789abcdef0123456789abcdef"} + _, err := p.FilePath() + require.Error(t, err) + assertNoEntries(t, profilesRoot) +} + +func TestSudoActiveSurvivesLookupFailure(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + _, ok := sudoInvokingUser() + assert.False(t, ok) + assert.True(t, sudoActive()) + assert.True(t, IsPlainRoot()) +} + +func TestGetConfigDirUnderSudoIsReadOnly(t *testing.T) { + home := t.TempDir() + fakeSudo(t, home) + + base, err := baseConfigDir() + require.NoError(t, err) + if runtime.GOOS == "darwin" { + assert.Equal(t, filepath.Join(home, "Library", "Application Support"), base) + } else { + assert.Equal(t, filepath.Join(home, ".config"), base) + } + + dir, err := getConfigDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(base, "netbird"), dir) + assert.NoDirExists(t, dir) +} + +func TestBaseConfigDirFailsClosedWhenSudoLookupFails(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + _, err := baseConfigDir() + require.Error(t, err) + + _, err = getConfigDir() + require.Error(t, err) +} + +func TestSwitchProfileSkipsStateWriteUnderSudo(t *testing.T) { + home := t.TempDir() + fakeSudo(t, home) + + pm := NewProfileManager() + require.NoError(t, pm.SwitchProfile(defaultProfileName)) + assertNoEntries(t, home) +} + +func TestSetProfileStateSkipsWriteUnderSudo(t *testing.T) { + home := t.TempDir() + fakeSudo(t, home) + + pm := NewProfileManager() + require.NoError(t, pm.SetProfileState(defaultProfileName, &ProfileState{Email: "misha@example.com"})) + assertNoEntries(t, home) +} + +func TestRemoveProfileStateSkipsRemoveUnderSudo(t *testing.T) { + home := t.TempDir() + stateDir := filepath.Join(home, ".config", "netbird") + if runtime.GOOS == "darwin" { + stateDir = filepath.Join(home, "Library", "Application Support", "netbird") + } + require.NoError(t, os.MkdirAll(stateDir, 0o700)) + stateFile := filepath.Join(stateDir, "default.state.json") + require.NoError(t, os.WriteFile(stateFile, []byte(`{"email":"misha@example.com"}`), 0o600)) + + fakeSudo(t, home) + pm := NewProfileManager() + require.NoError(t, pm.RemoveProfileState("default")) + assert.FileExists(t, stateFile) +} + +func TestUserBaseConfigDir(t *testing.T) { + u := &user.User{Username: "misha", HomeDir: filepath.Join("/home", "misha")} + dir, err := userBaseConfigDir(u) + require.NoError(t, err) + if runtime.GOOS == "darwin" { + assert.Equal(t, filepath.Join(u.HomeDir, "Library", "Application Support"), dir) + } else { + assert.Equal(t, filepath.Join(u.HomeDir, ".config"), dir) + } + + _, err = userBaseConfigDir(&user.User{Username: "nohome"}) + require.Error(t, err) +} + +func TestIsPlainRoot(t *testing.T) { + t.Setenv(envSudoUser, "") + origEuid := geteuid + t.Cleanup(func() { geteuid = origEuid }) + + geteuid = func() int { return 1000 } + assert.False(t, IsPlainRoot()) + + geteuid = func() int { return 0 } + assert.True(t, IsPlainRoot()) +} + +func TestMirrorIsAuthoritative(t *testing.T) { + t.Setenv(envSudoUser, "") + origEuid := geteuid + t.Cleanup(func() { geteuid = origEuid }) + + geteuid = func() int { return 1000 } + assert.True(t, MirrorIsAuthoritative(), "a normal user's own mirror is authoritative") + + geteuid = func() int { return 0 } + assert.False(t, MirrorIsAuthoritative(), "plain root has no authoritative mirror") +} + +func TestMirrorIsAuthoritativeFalseUnderSudo(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + assert.False(t, MirrorIsAuthoritative(), "the sudo mirror is frozen, so it is not authoritative") +} + +func fakeSudo(t *testing.T, home string) { + t.Helper() + t.Setenv(envSudoUser, "misha") + + origEuid := geteuid + origLookup := lookupUser + origOverride := ConfigDirOverride + geteuid = func() int { return 0 } + lookupUser = func(name string) (*user.User, error) { + return &user.User{Username: name, Uid: "1234", Gid: "1234", HomeDir: home}, nil + } + ConfigDirOverride = "" + t.Cleanup(func() { + geteuid = origEuid + lookupUser = origLookup + ConfigDirOverride = origOverride + }) +} + +func assertNoEntries(t *testing.T, root string) { + t.Helper() + err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + if path != root { + t.Errorf("unexpected entry created under %s: %s", root, path) + } + return nil + }) + require.NoError(t, err) +} diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go index e25d493d5..d2ed92bc5 100644 --- a/client/internal/profilemanager/profilemanager.go +++ b/client/internal/profilemanager/profilemanager.go @@ -3,7 +3,6 @@ package profilemanager import ( "fmt" "os" - "os/user" "path/filepath" "strings" "sync" @@ -54,7 +53,7 @@ func (p *Profile) FilePath() (string, error) { return "", fmt.Errorf("invalid profile ID: %q", id) } - username, err := user.Current() + username, err := InvokingUser() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) } @@ -130,7 +129,7 @@ func (pm *ProfileManager) getActiveProfileState() ID { if err != nil { if !os.IsNotExist(err) { log.Warnf("failed to read active profile state: %v", err) - } else { + } else if !sudoActive() { if err := pm.setActiveProfileState(defaultProfileName); err != nil { log.Warnf("failed to set default profile state: %v", err) } @@ -148,6 +147,13 @@ func (pm *ProfileManager) getActiveProfileState() ID { } func (pm *ProfileManager) setActiveProfileState(id ID) error { + // The invoking user's state is read-only under sudo — a root-owned file in + // the user's directory would break their own runs. The daemon still records + // the switch on its side; only the user-local bookkeeping is skipped. + if sudoActive() { + log.Infof("running under sudo: not persisting active profile %q for user %s", id, os.Getenv(envSudoUser)) + return nil + } configDir, err := getConfigDir() if err != nil { diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index ddb5dd056..81e6c085f 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/util" ) @@ -63,6 +65,15 @@ func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error { return fmt.Errorf("invalid profile ID: %q", id) } + // The invoking user's state is read-only under sudo. The file only carries + // the account email for the login hint and display, so skipping the write + // costs at most one extra account prompt later — a root-owned file in the + // user's directory would cost every later update instead. + if sudoActive() { + log.Debugf("running under sudo: not persisting profile state for user %s", os.Getenv(envSudoUser)) + return nil + } + stateFile := filepath.Join(configDir, id.String()+".state.json") if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil { return fmt.Errorf("write profile state: %w", err) @@ -92,6 +103,11 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { // equivalent to clearing it; the next SSO login recreates it. A missing file // is not an error. func (pm *ProfileManager) RemoveProfileState(profileName string) error { + if sudoActive() { + log.Debugf("running under sudo: not removing profile state for user %s", os.Getenv(envSudoUser)) + return nil + } + configDir, err := getConfigDir() if err != nil { return fmt.Errorf("get config directory: %w", err) From 7a9582db16e73d55a2ea4d5d95c30f5dc9efe770 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Tue, 1 Sep 2026 04:03:16 -0700 Subject: [PATCH 29/40] [management,proxy] Add agentgateway integration (#7274) * [management] Add agentgateway provider catalog entry Allow Agent Network providers to target an operator-supplied agentgateway proxy while stamping trusted NetBird identity headers. Signed-off-by: Daneyon Hansen * [proxy] Allow trusted Agent Network identity headers Permit only the built-in identity injector to replace the two reserved agentgateway attribution headers while keeping them blocked for every other middleware. Signed-off-by: Daneyon Hansen * [management,proxy] Add multi-vendor gateway routing Let one Agent Network route declare multiple parser surfaces while preserving the existing singular vendor wire field. Signed-off-by: Daneyon Hansen * [management] Update router test for model policies Signed-off-by: Daneyon Hansen * [proxy] Cover reserved header policy Signed-off-by: Daneyon Hansen * [management] Add agentgateway model discovery Use agentgateway's OpenAI-compatible models endpoint and omit wildcard patterns until NetBird can authorize and price them consistently. Signed-off-by: Daneyon Hansen --------- Signed-off-by: Daneyon Hansen --- .../modules/agentnetwork/catalog/catalog.go | 46 ++++++++++++-- .../agentnetwork/catalog/catalog_test.go | 50 +++++++++++++++ .../agentnetwork/modeldiscovery/discovery.go | 8 ++- .../modeldiscovery/discovery_test.go | 27 ++++++++ .../modules/agentnetwork/synthesizer.go | 13 ++++ .../modules/agentnetwork/synthesizer_test.go | 51 +++++++++++++++- .../middleware/builtin/llm_router/factory.go | 5 +- .../builtin/llm_router/middleware.go | 30 ++++++--- .../builtin/llm_router/middleware_test.go | 61 +++++++++++++++++++ proxy/internal/middleware/chain.go | 2 +- proxy/internal/middleware/chain_test.go | 59 ++++++++++++++++++ proxy/internal/middleware/headerpolicy.go | 18 +++++- .../internal/middleware/headerpolicy_test.go | 26 ++++++++ 13 files changed, 372 insertions(+), 24 deletions(-) create mode 100644 proxy/internal/middleware/headerpolicy_test.go diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index 3c7b995e5..b58743798 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -81,6 +81,10 @@ type Provider struct { // surface — the proxy middleware then falls back to URL sniffing // or skips request-side enrichment. ParserID string + // RouterVendors declares every parser surface a gateway route can serve. + // Leave empty for single-surface providers, where ParserID remains the + // router discriminator for backward compatibility. + RouterVendors []string // PricingSurfaces names the cost-meter pricing surfaces this // provider's Models are priced under ("openai", "anthropic", // "bedrock" — the llm.Parser surface the request parser stamps as @@ -116,8 +120,7 @@ type Provider struct { // 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. + // Models above. Nil entries keep free-text entry. Discovery *Discovery } @@ -154,10 +157,13 @@ const ( // 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 + Host string + Path string + Query string + Shape ListingShape + // ExactModelsOnly omits wildcard patterns from listings when NetBird's + // provider model rows cannot represent the vendor's matching semantics. + ExactModelsOnly bool // 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. @@ -635,6 +641,34 @@ var providers = []Provider{ }, Models: []Model{}, }, + { + ID: "agentgateway", + Kind: KindGateway, + Name: "agentgateway", + Description: "Bring your own agentgateway with trusted NetBird identity stamped on every request", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#8023C3", + // Agentgateway accepts both OpenAI and Anthropic request shapes. + // Leave ParserID empty so the proxy detects the shape from the URL. + ParserID: "", + RouterVendors: []string{"openai", "anthropic"}, + PricingSurfaces: []string{"openai", "anthropic"}, + Discovery: &Discovery{ + Path: "/v1/models", + Shape: ShapeOpenAIData, + ExactModelsOnly: true, + }, + IdentityInjection: &IdentityInjection{ + HeaderPair: &HeaderPairInjection{ + EndUserIDHeader: "x-netbird-user-id", + TagsHeader: "x-netbird-groups", + }, + }, + Models: []Model{}, + }, { ID: "portkey", Kind: KindGateway, diff --git a/management/internals/modules/agentnetwork/catalog/catalog_test.go b/management/internals/modules/agentnetwork/catalog/catalog_test.go index e4e887e6f..8abd3a312 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog_test.go +++ b/management/internals/modules/agentnetwork/catalog/catalog_test.go @@ -5,6 +5,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/http/api" ) // TestClaudeLineupSelectable pins the models Claude Code resolves to by @@ -34,3 +36,51 @@ func TestClaudeLineupSelectable(t *testing.T) { } } } + +func TestAgentgatewayCatalogEntry(t *testing.T) { + entry, ok := Lookup("agentgateway") + require.True(t, ok, "agentgateway must be available in the provider catalog") + + assert.Equal(t, KindGateway, entry.Kind, "agentgateway must be grouped with AI gateways") + assert.Empty(t, entry.DefaultHost, "operators must provide their agentgateway proxy URL") + assert.Equal(t, "Authorization", entry.AuthHeaderName) + assert.Equal(t, "Bearer ${API_KEY}", entry.AuthHeaderTemplate) + assert.Equal(t, "application/json", entry.DefaultContentType) + assert.Empty(t, entry.ParserID, "URL detection must select the OpenAI or Anthropic parser") + assert.Equal(t, []string{"openai", "anthropic"}, entry.RouterVendors, + "agentgateway must accept both parser surfaces") + assert.Equal(t, []string{"openai", "anthropic"}, entry.PricingSurfaces, + "agentgateway models can use either pricing surface") + assert.Empty(t, entry.Models, "an empty model list makes agentgateway a catch-all route") + require.NotNil(t, entry.Discovery) + assert.Empty(t, entry.Discovery.Host, "discovery must use the configured proxy URL") + assert.Equal(t, "/v1/models", entry.Discovery.Path) + assert.Equal(t, ShapeOpenAIData, entry.Discovery.Shape) + assert.True(t, entry.Discovery.ExactModelsOnly, + "wildcard model semantics are not supported by NetBird") + + require.NotNil(t, entry.IdentityInjection) + require.NotNil(t, entry.IdentityInjection.HeaderPair) + assert.Nil(t, entry.IdentityInjection.JSONMetadata) + assert.False(t, entry.IdentityInjection.HeaderPair.Customizable, + "NetBird identity header names are part of the integration contract") + assert.Equal(t, "x-netbird-user-id", entry.IdentityInjection.HeaderPair.EndUserIDHeader) + assert.Equal(t, "x-netbird-groups", entry.IdentityInjection.HeaderPair.TagsHeader) + assert.False(t, entry.IdentityInjection.HeaderPair.EndUserIDInBody) + assert.False(t, entry.IdentityInjection.HeaderPair.TagsInBody) +} + +func TestAgentgatewayCatalogAPIResponse(t *testing.T) { + entry, ok := Lookup("agentgateway") + require.True(t, ok) + + resp := entry.ToAPIResponse() + assert.Equal(t, "agentgateway", resp.Id) + assert.Equal(t, api.AgentNetworkCatalogProviderKindGateway, resp.Kind) + assert.Empty(t, resp.Models) + require.NotNil(t, resp.IdentityInjection) + require.NotNil(t, resp.IdentityInjection.HeaderPair) + assert.False(t, resp.IdentityInjection.HeaderPair.Customizable) + assert.Equal(t, "x-netbird-user-id", resp.IdentityInjection.HeaderPair.EndUserIdHeader) + assert.Equal(t, "x-netbird-groups", resp.IdentityInjection.HeaderPair.TagsHeader) +} diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go index 253cc63b3..c9f2b09df 100644 --- a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go @@ -52,9 +52,8 @@ const ( ) // 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. +// endpoint. 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 @@ -356,6 +355,9 @@ func decorate(entry catalog.Provider, ids []listedModel) []Model { if listed.id == "" { continue } + if entry.Discovery.ExactModelsOnly && strings.Contains(listed.id, "*") { + continue + } if _, dup := seen[listed.id]; dup { continue } diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go index 133bd5148..59b21a2fe 100644 --- a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go @@ -59,6 +59,13 @@ const openAIListing = `{"object":"list","data":[ {"id":"gpt-4o","object":"model","created":1715367049,"owned_by":"system"} ]}` +const agentgatewayListing = `{"object":"list","data":[ + {"id":"gpt-4o-mini","object":"model","created":1785166485,"owned_by":"openai"}, + {"id":"claude-haiku-4-5","object":"model","created":1785166485,"owned_by":"anthropic"}, + {"id":"openai/*","object":"model","created":1785166485,"owned_by":"openai"}, + {"id":"*-latest","object":"model","created":1785166485,"owned_by":"openai"} +]}` + 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"} @@ -97,6 +104,26 @@ func TestFetchOpenAIListing(t *testing.T) { } } +func TestFetchAgentgatewayListing(t *testing.T) { + cl, tr := newStubClient(http.StatusOK, agentgatewayListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "agentgateway", + UpstreamURL: "https://gateway.example.com", + APIKey: "virtual-key", + }) + require.NoError(t, err) + + assert.Equal(t, "https://gateway.example.com/v1/models", tr.got.URL.String()) + assert.Equal(t, "Bearer virtual-key", tr.got.Header.Get("Authorization"), + "agentgateway model discovery must use the configured virtual key") + assert.Equal(t, []string{"gpt-4o-mini", "claude-haiku-4-5"}, ids(models), + "model patterns must not be offered as exact NetBird authorization rows") + for _, m := range models { + assert.True(t, m.PricingKnown, "known upstream model must use NetBird catalog pricing: %s", m.ID) + } +} + func TestFetchAnthropicSendsTheVersionHeader(t *testing.T) { cl, tr := newStubClient(http.StatusOK, anthropicListing) diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 66a19acd9..b838ac547 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -352,6 +352,7 @@ type routerConfig struct { type routerProviderRoute struct { ID string `json:"id"` Vendor string `json:"vendor,omitempty"` + Vendors []string `json:"vendors,omitempty"` Models []string `json:"models"` UpstreamScheme string `json:"upstream_scheme"` UpstreamHost string `json:"upstream_host"` @@ -461,6 +462,7 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][] cfg.Providers = append(cfg.Providers, routerProviderRoute{ ID: p.ID, Vendor: providerVendor(p), + Vendors: providerVendors(p), Models: providerModelIDs(p), UpstreamScheme: scheme, UpstreamHost: host, @@ -525,6 +527,17 @@ func providerVendor(p *types.Provider) string { return entry.ParserID } +// providerVendors returns the parser surfaces a multi-surface gateway route +// accepts. Single-surface providers keep using the singular vendor field so +// existing proxy versions and configurations retain their wire shape. +func providerVendors(p *types.Provider) []string { + entry, ok := catalog.Lookup(p.ProviderID) + if !ok || len(entry.RouterVendors) == 0 { + return nil + } + return append([]string(nil), entry.RouterVendors...) +} + // providerModelIDs returns the model identifiers exposed by the // provider, deduplicated and in the operator's declared order. Empty // slice when no models are configured — the router treats that as diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 352d36646..6aeafadbd 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" @@ -497,6 +497,55 @@ func TestSynthesizeServices_IdentityInject_LiteLLM(t *testing.T) { assert.Equal(t, "x-litellm-tags", entry.HeaderPair.TagsHeader) } +func TestBuildIdentityInjectConfigJSON_Agentgateway(t *testing.T) { + provider := &types.Provider{ + ID: "prov-agentgateway", + ProviderID: "agentgateway", + } + + raw, err := buildIdentityInjectConfigJSON( + []*types.Provider{provider}, + map[string][]string{provider.ID: []string{"grp-eng"}}, + ) + require.NoError(t, err) + + var cfg identityInjectConfig + require.NoError(t, json.Unmarshal(raw, &cfg)) + require.Len(t, cfg.Providers, 1) + + rule := cfg.Providers[0] + assert.Equal(t, provider.ID, rule.ProviderID) + require.NotNil(t, rule.HeaderPair) + assert.Nil(t, rule.JSONMetadata) + assert.Equal(t, "x-netbird-user-id", rule.HeaderPair.EndUserIDHeader) + assert.Equal(t, "x-netbird-groups", rule.HeaderPair.TagsHeader) + assert.False(t, rule.HeaderPair.EndUserIDInBody) + assert.False(t, rule.HeaderPair.TagsInBody) +} + +func TestBuildRouterConfigJSON_AgentgatewayVendors(t *testing.T) { + provider := &types.Provider{ + ID: "prov-agentgateway", + ProviderID: "agentgateway", + UpstreamURL: "https://gateway.example.com", + APIKey: "virtual-key", + } + + raw, err := buildRouterConfigJSON( + []*types.Provider{provider}, + map[string][]string{provider.ID: {"grp-eng"}}, + nil, + ) + require.NoError(t, err) + + var cfg routerConfig + require.NoError(t, json.Unmarshal(raw, &cfg)) + require.Len(t, cfg.Providers, 1) + assert.Empty(t, cfg.Providers[0].Vendor, + "the singular vendor remains empty for a multi-surface gateway") + assert.Equal(t, []string{"openai", "anthropic"}, cfg.Providers[0].Vendors) +} + // TestSynthesizeServices_IdentityInject_Bifrost_OperatorOverrides // covers the customizable HeaderPair contract. The Bifrost catalog // entry sets HeaderPair.Customizable=true with x-bf-dim-* defaults diff --git a/proxy/internal/middleware/builtin/llm_router/factory.go b/proxy/internal/middleware/builtin/llm_router/factory.go index 81b8727f1..70a2179b5 100644 --- a/proxy/internal/middleware/builtin/llm_router/factory.go +++ b/proxy/internal/middleware/builtin/llm_router/factory.go @@ -36,7 +36,10 @@ type ProviderRoute struct { // request on a same-vendor route so catch-all gateways of a different // vendor can't swallow it. Empty disables vendor filtering for this // route. - Vendor string `json:"vendor,omitempty"` + Vendor string `json:"vendor,omitempty"` + // Vendors lists every parser surface a multi-surface gateway accepts. + // Vendor remains supported for existing single-surface configurations. + Vendors []string `json:"vendors,omitempty"` Models []string `json:"models"` UpstreamScheme string `json:"upstream_scheme"` UpstreamHost string `json:"upstream_host"` diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index b8d4b001b..6381f01c7 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -409,7 +409,7 @@ func stripBedrockNamespace(out *middleware.Output) { // peer, return matchOutcomeUnauthorised so the caller can emit // the dedicated no_authorised_provider deny code. // 3. Vendor precedence: when the request carries a detected vendor -// (llm.provider) and at least one candidate is the same vendor, +// (llm.provider) and at least one candidate declares that vendor, // drop the rest — a vendor-tagged request must never cross to // another vendor's route (e.g. an Anthropic call landing on an // OpenAI-compatible gateway that also claims the model). @@ -432,9 +432,9 @@ func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []stri // Vendor pinning runs BEFORE the group filter so a request the parser // tagged with a vendor can never cross to another vendor's route — not - // even an authorised one. Narrow to same-vendor routes when any - // model-matched route declares that vendor; setups with no vendor tag on - // any route fall through unchanged. After narrowing, if no same-vendor + // even an authorised one. Narrow to supporting routes when any + // model-matched route declares that vendor; setups with no matching vendor + // declaration fall through unchanged. After narrowing, if no supporting // route authorises the caller, that's matchOutcomeUnauthorised (no // cross-vendor fallback). if vendor != "" { @@ -805,21 +805,31 @@ func authorisingGroupsCSV(routeGroups, userGroups []string) string { return strings.Join(out, ",") } -// matchingVendor returns the subset of routes whose Vendor equals the -// request's detected vendor. Routes with an empty Vendor never match — an -// untagged route can't be asserted to speak the request's surface, so it -// stays out of the vendor-filtered set (but remains eligible via the -// fall-through when no route matches the vendor at all). +// matchingVendor returns the routes that declare the request's detected +// vendor through either the legacy singular field or the multi-vendor field. +// Untagged routes remain eligible only when no route declares the vendor. func matchingVendor(routes []ProviderRoute, vendor string) []ProviderRoute { var out []ProviderRoute for _, r := range routes { - if r.Vendor == vendor { + if routeSupportsVendor(r, vendor) { out = append(out, r) } } return out } +func routeSupportsVendor(route ProviderRoute, vendor string) bool { + if route.Vendor == vendor { + return true + } + for _, candidate := range route.Vendors { + if candidate == vendor { + return true + } + } + return false +} + // explicitlyClaiming returns the subset of routes whose Models list // names the model exactly. Catch-all routes (empty Models) are excluded, // so callers can prefer a provider that genuinely declares the model over diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go index 5a1d32480..8612d8f18 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go @@ -412,6 +412,50 @@ func TestRouter_VendorKeepsOpenAIOffAnthropic(t *testing.T) { assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, "openai vendor must pin to the openai route despite anthropic being declared first") } +func TestRouter_MultiVendorGatewayAcceptsBothSurfaces(t *testing.T) { + gateway := ProviderRoute{ + ID: "agentgateway", + Vendors: []string{"openai", "anthropic"}, + Models: nil, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "gateway.example.com", + } + other := ProviderRoute{ + ID: "other-vendor", + Vendor: "mistral", + Models: nil, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "mistral.example.com", + } + mw := New(Config{Providers: []ProviderRoute{other, gateway}}) + + for _, tc := range []struct { + name string + vendor string + model string + path string + }{ + {name: "OpenAI", vendor: "openai", model: "gpt-4o-mini", path: "/v1/chat/completions"}, + {name: "Anthropic", vendor: "anthropic", model: "claude-sonnet-4-5", path: "/v1/messages"}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := mw.Invoke(context.Background(), newInputVendorModelURL(tc.vendor, tc.model, tc.path)) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "supported vendor must route through the multi-surface gateway") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "gateway.example.com", out.Mutations.RewriteUpstream.Host) + + provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "agentgateway", provider) + }) + } +} + // TestRouter_VendorAbsentFallsBackToModelPath confirms vendor filtering is // inert when the request carries no detected vendor: routing then relies on // model/path as before. @@ -692,6 +736,23 @@ func TestRouter_FactoryRejectsBadJSON(t *testing.T) { require.Error(t, err, "malformed JSON config must be rejected at chain build time") } +func TestRouter_FactoryDecodesLegacyAndMultiVendorFields(t *testing.T) { + raw := []byte(`{"providers":[` + + `{"id":"legacy","vendor":"openai","models":[],"upstream_scheme":"https","upstream_host":"openai.example.com","auth_header_name":"Authorization","auth_header_value":"Bearer legacy","allowed_group_ids":["group"]},` + + `{"id":"multi","vendors":["openai","anthropic"],"models":[],"upstream_scheme":"https","upstream_host":"gateway.example.com","auth_header_name":"Authorization","auth_header_value":"Bearer multi","allowed_group_ids":["group"]}` + + `]}`) + + resolved, err := Factory{}.New(raw) + require.NoError(t, err) + router, ok := resolved.(*Middleware) + require.True(t, ok, "factory must return the concrete router middleware") + require.Len(t, router.cfg.Providers, 2) + assert.Equal(t, "openai", router.cfg.Providers[0].Vendor, + "the legacy singular field must keep decoding") + assert.Equal(t, []string{"openai", "anthropic"}, router.cfg.Providers[1].Vendors, + "the multi-vendor field must decode both supported surfaces") +} + func TestRouter_FactoryAcceptsEmptyShapes(t *testing.T) { cases := [][]byte{nil, []byte(""), []byte(" "), []byte("null"), []byte("{}"), []byte("[]")} for _, raw := range cases { diff --git a/proxy/internal/middleware/chain.go b/proxy/internal/middleware/chain.go index 45d32cdb0..9eed93678 100644 --- a/proxy/internal/middleware/chain.go +++ b/proxy/internal/middleware/chain.go @@ -264,7 +264,7 @@ func applyMutations(ctx context.Context, d *Dispatcher, spec Spec, r *http.Reque if m == nil { return } - add, remove, blocked := FilterHeaderMutations(m) + add, remove, blocked := filterHeaderMutations(m, spec.ID) for _, h := range blocked { d.metrics.IncHeaderMutationBlocked(ctx, spec.ID, h) } diff --git a/proxy/internal/middleware/chain_test.go b/proxy/internal/middleware/chain_test.go index 929ccee08..ffb23271e 100644 --- a/proxy/internal/middleware/chain_test.go +++ b/proxy/internal/middleware/chain_test.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "net/http" "strconv" "testing" @@ -278,6 +279,64 @@ func TestChain_ApplyMutations_RewriteGatedOnCanMutate(t *testing.T) { assert.Nil(t, rewrite, "rewrite must be filtered when CanMutate=false") } +func TestChain_IdentityInjectReplacesReservedNetBirdHeaders(t *testing.T) { + mw := &fakeMiddleware{ + id: "llm_identity_inject", + slot: SlotOnRequest, + mutationsSupported: true, + canMutate: true, + mutations: &Mutations{ + HeadersRemove: []string{"x-netbird-user-id", "x-netbird-groups"}, + HeadersAdd: []KV{ + {Key: "x-netbird-user-id", Value: "trusted-user"}, + {Key: "x-netbird-groups", Value: "trusted-group"}, + }, + }, + } + c := chainFor(t, mw) + req, err := http.NewRequest(http.MethodGet, "https://gateway.example.com/v1/models", nil) + require.NoError(t, err) + req.Header.Set("x-netbird-user-id", "spoofed-user") + req.Header.Set("x-netbird-groups", "spoofed-group") + + denied, _, _, err := c.RunRequest(context.Background(), req, &Input{}, NewAccumulator(0)) + require.NoError(t, err) + assert.Nil(t, denied, "identity injection must not deny the request") + assert.Equal(t, "trusted-user", req.Header.Get("x-netbird-user-id"), + "the built-in identity middleware must replace a spoofed user header") + assert.Equal(t, "trusted-group", req.Header.Get("x-netbird-groups"), + "the built-in identity middleware must replace spoofed groups") +} + +func TestChain_OtherMiddlewareCannotReplaceReservedNetBirdHeaders(t *testing.T) { + mw := &fakeMiddleware{ + id: "untrusted-middleware", + slot: SlotOnRequest, + mutationsSupported: true, + canMutate: true, + mutations: &Mutations{ + HeadersRemove: []string{"x-netbird-user-id", "x-netbird-groups"}, + HeadersAdd: []KV{ + {Key: "x-netbird-user-id", Value: "replacement-user"}, + {Key: "x-netbird-groups", Value: "replacement-group"}, + }, + }, + } + c := chainFor(t, mw) + req, err := http.NewRequest(http.MethodGet, "https://gateway.example.com/v1/models", nil) + require.NoError(t, err) + req.Header.Set("x-netbird-user-id", "original-user") + req.Header.Set("x-netbird-groups", "original-group") + + denied, _, _, err := c.RunRequest(context.Background(), req, &Input{}, NewAccumulator(0)) + require.NoError(t, err) + assert.Nil(t, denied, "blocked mutations must not deny the request") + assert.Equal(t, "original-user", req.Header.Get("x-netbird-user-id"), + "other middleware must remain unable to mutate reserved identity headers") + assert.Equal(t, "original-group", req.Header.Get("x-netbird-groups"), + "other middleware must remain unable to mutate reserved identity headers") +} + // TestChain_RunRequest_PropagatesUserGroups asserts the chain forwards // Input.UserGroups verbatim through cloneInputFor so policy-aware // middlewares (e.g. llm_policy_check) can authorise without an extra diff --git a/proxy/internal/middleware/headerpolicy.go b/proxy/internal/middleware/headerpolicy.go index d041ad1e1..b1fa564c2 100644 --- a/proxy/internal/middleware/headerpolicy.go +++ b/proxy/internal/middleware/headerpolicy.go @@ -2,6 +2,8 @@ package middleware import "strings" +const trustedIdentityMiddlewareID = "llm_identity_inject" + var denyHeaders = []string{ "Authorization", "Connection", @@ -78,18 +80,22 @@ func isHeaderFieldName(name string) bool { // header names so the dispatcher can increment the blocked-header // metric. func FilterHeaderMutations(m *Mutations) (filteredAdd []KV, filteredRemove []string, blocked []string) { + return filterHeaderMutations(m, "") +} + +func filterHeaderMutations(m *Mutations, middlewareID string) (filteredAdd []KV, filteredRemove []string, blocked []string) { if m == nil { return nil, nil, nil } for _, kv := range m.HeadersAdd { - if IsHeaderMutable(kv.Key) { + if IsHeaderMutable(kv.Key) || isTrustedIdentityHeader(middlewareID, kv.Key) { filteredAdd = append(filteredAdd, kv) continue } blocked = append(blocked, kv.Key) } for _, name := range m.HeadersRemove { - if IsHeaderMutable(name) { + if IsHeaderMutable(name) || isTrustedIdentityHeader(middlewareID, name) { filteredRemove = append(filteredRemove, name) continue } @@ -97,3 +103,11 @@ func FilterHeaderMutations(m *Mutations) (filteredAdd []KV, filteredRemove []str } return filteredAdd, filteredRemove, blocked } + +func isTrustedIdentityHeader(middlewareID, name string) bool { + if middlewareID != trustedIdentityMiddlewareID { + return false + } + return strings.EqualFold(name, "x-netbird-user-id") || + strings.EqualFold(name, "x-netbird-groups") +} diff --git a/proxy/internal/middleware/headerpolicy_test.go b/proxy/internal/middleware/headerpolicy_test.go new file mode 100644 index 000000000..7daa93eec --- /dev/null +++ b/proxy/internal/middleware/headerpolicy_test.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilterHeaderMutationsDoesNotTrustReservedHeaders(t *testing.T) { + mutations := &Mutations{ + HeadersAdd: []KV{ + {Key: "x-request-label", Value: "allowed"}, + {Key: "x-netbird-user-id", Value: "spoofed-user"}, + }, + HeadersRemove: []string{"x-request-label", "x-netbird-groups"}, + } + + filteredAdd, filteredRemove, blocked := FilterHeaderMutations(mutations) + + assert.Equal(t, []KV{{Key: "x-request-label", Value: "allowed"}}, filteredAdd, + "the public filter should retain mutable additions") + assert.Equal(t, []string{"x-request-label"}, filteredRemove, + "the public filter should retain mutable removals") + assert.ElementsMatch(t, []string{"x-netbird-user-id", "x-netbird-groups"}, blocked, + "the public filter must not grant the identity middleware exception") +} From 652d5f3c15698635690d9e029a1a05e303957a9c Mon Sep 17 00:00:00 2001 From: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:29:26 +0200 Subject: [PATCH 30/40] [client] Reuse the profile's account for iOS SSO logins (#7193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Reuse the profile's account for iOS SSO logins Android reads the profile's stored account and passes it as the OIDC login_hint, and records it again after a successful login. iOS did neither: it called GetOAuthFlow with an empty hint, so a re-login was resolved by whatever session the browser's cookie jar held rather than by the account the profile belongs to. With a non-ephemeral browser session that is the wrong account as soon as more than one is signed in. Mirror client/android/login.go: hint from mobile.ReadProfileEmail before the flow, mobile.WriteProfileEmail after Login succeeds. Storing after Login and not before keeps a rejected token from leaving a hint that points at an account which cannot be used. Co-Authored-By: Claude Opus 5 * [client] Persist the account email on tvOS and on the device flow Two paths left a profile with no account bound, so every later login went out without a login_hint — the case this change exists to remove. WriteProfileEmail went through util.WriteJsonWithRestrictedPermission, which writes a temp file and renames it over the target. The tvOS App Group sandbox blocks exactly that, which is why the config sitting next to this file is written with DirectWriteOutConfig. On tvOS the email write therefore failed and was dropped with a warning. Use DirectWriteJson: the file is rewritten whole from a single key, so the only thing atomicity buys here is surviving a crash mid-write, and a torn file reads back as "no email" and is replaced by the next login. The device authorization flow never populated TokenInfo.Email, unlike the PKCE flow, so a client driven through it — Android TV and tvOS — bound no account at all. Parse the ID token there too. Co-Authored-By: Claude Opus 5 * [client] Report a failed close from DirectWriteJson The deferred close assigned its error to err, but the return value was not named, so the assignment went nowhere: a close that failed was logged and the function still returned nil. The write is only durable once the file closes cleanly, so every caller — the management config, the profile configs and the profile account email — could be told the data landed when it had not. Name the return so the assignment does what its shape always intended, and report the failure once. When the body succeeded the close error is returned and the caller logs it. When the body already failed, that error is the one that explains the failure and is what the caller gets, which leaves the deferred log as the only place the close failure can surface — at debug, per the logging rules for close errors on writes. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- client/internal/auth/device_flow.go | 10 ++++++++++ client/ios/NetBirdSDK/login.go | 27 ++++++++++++++++++++++++++- client/mobile/profile_state.go | 8 +++++++- util/file.go | 23 ++++++++++++++++++----- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go index 9dec7cf53..3592e589d 100644 --- a/client/internal/auth/device_flow.go +++ b/client/internal/auth/device_flow.go @@ -304,6 +304,16 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err) } + // Same as the PKCE flow: the account the token belongs to is what + // callers store to send back as the login_hint. Without it a client + // driven through the device flow — Android TV and tvOS — never binds + // an account to its profile and every later login goes out blind. + if email, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil { + log.Warnf("failed to parse email from ID token: %v", err) + } else { + tokenInfo.Email = email + } + log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second)) return tokenInfo, err } diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 42a575359..cf7aa6730 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -11,6 +11,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" ) @@ -284,12 +285,14 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin } jwtToken := "" + email := "" if needsLogin { tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, forceDeviceAuth) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } jwtToken = tokenInfo.GetTokenToUse() + email = tokenInfo.Email } err, isAuthError := authClient.Login(ctx, "", jwtToken) @@ -301,6 +304,14 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin return fmt.Errorf("login failed: %v", err) } + // 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 := mobile.WriteProfileEmail(a.cfgPath, email); err != nil { + log.Warnf("failed to store profile account email: %v", err) + } + } + // Save the config before notifying success to ensure persistence completes // before the callback potentially triggers teardown on the Swift side. // Note: This differs from Android which doesn't save config after login. @@ -320,10 +331,24 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin return nil } +// profileLoginHint returns the stored account email for the profile at cfgPath, +// so a re-login targets the account the profile already belongs to instead of +// whatever session the shared browser cookie jar happens to hold. +// +// An empty hint is deliberate, not a fallback: a fresh profile leaves the +// choice to the IdP. Switching accounts is done by switching or removing +// profiles, not by logging out — logout keeps the email. +func profileLoginHint(cfgPath string) string { + if cfgPath == "" { + return "" + } + return mobile.ReadProfileEmail(cfgPath) +} + const authInfoRequestTimeout = 30 * time.Second func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) { - oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, "") + oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, profileLoginHint(a.cfgPath)) if err != nil { return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } diff --git a/client/mobile/profile_state.go b/client/mobile/profile_state.go index bb983ec1d..ad05801f8 100644 --- a/client/mobile/profile_state.go +++ b/client/mobile/profile_state.go @@ -78,8 +78,14 @@ func WriteProfileEmail(configPath string, email string) error { return fmt.Errorf("resolve profile account path: %w", err) } + // DirectWriteJson, not the atomic writers: those create a temp file and + // rename it over the target, which the tvOS App Group sandbox blocks. It is + // the same reason the config next to this file goes through + // DirectWriteOutConfig. The file is rewritten whole from one key, so losing + // atomicity costs nothing beyond a torn write on a crash mid-write, which + // reads back as "no email" and is recovered by the next login. state := profilemanager.ProfileState{Email: email} - if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil { + if err := util.DirectWriteJson(context.Background(), accountPath, state); err != nil { return fmt.Errorf("write profile account: %w", err) } diff --git a/util/file.go b/util/file.go index 926904f9f..52eb91c0f 100644 --- a/util/file.go +++ b/util/file.go @@ -56,9 +56,9 @@ func WriteJson(ctx context.Context, file string, obj interface{}) error { } // DirectWriteJson writes JSON config object to a file creating parent directories if required without creating a temporary file -func DirectWriteJson(ctx context.Context, file string, obj interface{}) error { +func DirectWriteJson(ctx context.Context, file string, obj interface{}) (err error) { - _, _, err := prepareConfigFileDir(file) + _, _, err = prepareConfigFileDir(file) if err != nil { return err } @@ -68,11 +68,24 @@ func DirectWriteJson(ctx context.Context, file string, obj interface{}) error { return err } + // Named return so a failed Close is reported rather than logged and + // swallowed: the write is only durable once the file closes cleanly, and a + // caller told "written" would carry on with data that never landed. defer func() { - err = targetFile.Close() - if err != nil { - log.Errorf("failed to close file %s: %v", file, err) + cerr := targetFile.Close() + if cerr == nil { + return } + if err == nil { + // Returned, not logged: the caller reports it once. + err = cerr + return + } + // The body already failed and that error is the one the caller gets, so + // it is the one that explains the failure. This is then the only place + // the close failure can surface — at debug, per the logging rules for + // close errors on writes. + log.Debugf("failed to close file %s after %v: %v", file, err, cerr) }() // make it pretty From 3027130f0f25159bebdb17440604984c292a2d1a Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 1 Sep 2026 14:35:38 +0200 Subject: [PATCH 31/40] [management] Add Agent Network access roles and self-service endpoints (#7221) Delegating Agent Network today means handing out full account admin, and regular users cannot see their own usage or how to connect a local tool. Add two roles on top of the existing agent_network permission submodules. agent_network_admin owns the whole area (providers, policies, guardrails, budgets, usage, logs, settings) with read-only users, groups, peers, and account info needed to build policies, and nothing else in the account. usage_viewer is the regular User baseline plus read on the aggregated usage and cost overview: no provider configuration, no policies, no request-level logs, which can contain captured prompts. billing_admin gets a proper permission-map entry with the User baseline so role resolution stops failing with role-not-found; its plan and invoice permissions stay enforced cloud-side. Add the self-service endpoints behind the "My Agent Network" view, available to every authenticated user because both answers are scoped strictly to the caller. GET /api/agent-network/me/setup returns the account endpoint plus the providers and models the caller's own groups authorize, computed with the same rules the proxy enforces: policy filtering as in policy selection, model allowlist union intersected with declared models, orphan and disabled providers omitted. Not set up and no access are deliberately indistinguishable, and the response carries display metadata only. GET /api/agent-network/me/consumption returns the caller's own user-dimension counters. --- agent-network/README.md | 36 ++ .../modules/agentnetwork/agent_config.go | 287 ++++++++++++++ .../agent_config_realstore_test.go | 358 ++++++++++++++++++ .../handlers/agent_config_handler.go | 56 +++ .../handlers/providers_handler.go | 1 + .../internals/modules/agentnetwork/manager.go | 158 +++++++- .../agentnetwork/provider_redaction_test.go | 289 ++++++++++++++ .../agentnetwork/types/agent_config.go | 42 ++ .../modules/agentnetwork/types/provider.go | 20 + .../permissions/agent_network_roles_test.go | 140 +++++++ .../permissions/roles/agent_network_admin.go | 62 +++ .../server/permissions/roles/billing_admin.go | 20 + .../permissions/roles/role_permissions.go | 13 +- .../server/permissions/roles/usage_viewer.go | 60 +++ management/server/types/user.go | 22 +- shared/management/http/api/openapi.yml | 76 +++- shared/management/http/api/types.gen.go | 30 ++ 17 files changed, 1645 insertions(+), 25 deletions(-) create mode 100644 management/internals/modules/agentnetwork/agent_config.go create mode 100644 management/internals/modules/agentnetwork/agent_config_realstore_test.go create mode 100644 management/internals/modules/agentnetwork/handlers/agent_config_handler.go create mode 100644 management/internals/modules/agentnetwork/provider_redaction_test.go create mode 100644 management/internals/modules/agentnetwork/types/agent_config.go create mode 100644 management/server/permissions/agent_network_roles_test.go create mode 100644 management/server/permissions/roles/agent_network_admin.go create mode 100644 management/server/permissions/roles/billing_admin.go create mode 100644 management/server/permissions/roles/usage_viewer.go diff --git a/agent-network/README.md b/agent-network/README.md index 5211fe8f9..029ada299 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -96,6 +96,42 @@ components: — the management-side control plane: providers, policies, guardrails, limits, routing, and usage/access logs. +## Access roles + +Agent Network permissions build on the account permission matrix +([`management/server/permissions/`](../management/server/permissions)). The +`agent_network` area is split into dotted submodules (`agent_network.providers`, +`.policies`, `.guardrails`, `.budgets`, `.usage`, `.logs`, `.settings`); a role may +grant a single submodule or the parent, which cascades to all of them. + +Two roles delegate Agent Network access without account-admin rights: + +- **`agent_network_admin`** — full control over the whole `agent_network` area plus + read-only users, groups, peers, and account info (needed to build policies). + Nothing else in the account. +- **`usage_viewer`** — the regular User baseline plus read on + `agent_network.usage` (the aggregated usage and cost overview) and read-only + access to the resources the usage filters resolve against: users, groups, + peers, and the provider list (connection config redacted — no upstream URLs + or operator-supplied header values). No policies, and no account-wide + request-level access logs; like any caller, it still reads its own requests + through the self-scoped endpoints below. + +Every authenticated user, regardless of role, can read the caller-scoped +self-service endpoint `GET /api/agent-network/agent-config` (the endpoint, providers, +and models the caller's own policies allow — what a local AI tool needs and nothing +more). The regular usage and access-log endpoints self-scope instead of denying: +a caller without the account-wide grant gets their own rows back, so "my usage" +and "my requests" are the same endpoints the admin dashboard uses. The provider +list self-scopes the same way — a caller without the providers grant gets the +providers their own policies authorize, reduced to the display surface, with +each provider's model list cut to what the caller's policy guardrails and the +provider's declared models effectively permit (the same computation the setup +answer and the proxy use). This feeds the dashboard's provider and model +filters. Role +definitions live in +[`management/server/permissions/roles/`](../management/server/permissions/roles). + ## Documentation Full documentation, architecture, and quickstart: diff --git a/management/internals/modules/agentnetwork/agent_config.go b/management/internals/modules/agentnetwork/agent_config.go new file mode 100644 index 000000000..5571fd159 --- /dev/null +++ b/management/internals/modules/agentnetwork/agent_config.go @@ -0,0 +1,287 @@ +package agentnetwork + +import ( + "context" + "fmt" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" +) + +// GetAgentConfigForUser returns the Agent Network setup the calling user's +// groups authorize. It deliberately performs no role permission check: +// the result is scoped to the caller's own groups, which is strictly +// tighter than any role gate, so every authenticated user (any role) may +// read it. The group source matches enforcement: the proxy authorizes +// each Agent Network request against the calling user's groups as well — +// session validation resolves them from the same user record's +// auto-groups — so this answer and the proxy's verdict are computed from +// the same memberships. +func (m *managerImpl) GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error) { + user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return m.agentConfigForGroups(ctx, accountID, user.AutoGroups) +} + +// agentConfigForGroups computes the effective Agent Network setup for +// a set of caller groups: the account endpoint plus, per authorized +// provider, the effective model set. It mirrors what the proxy enforces +// at request time — the policy filter matches filterApplicablePolicies, +// the model logic matches policyPermitsModel, and orphan providers +// (enabled but referenced by no applicable policy) are omitted just like +// the router synthesizer omits them — so the answer never advertises +// anything the proxy would refuse. +// +// Configured tracks the account, not the caller: once the account has an +// endpoint every member gets it, with Providers empty for those no policy +// covers yet. The dashboard shows each user the same connection config +// regardless of role, and an empty provider list tells them to ask for +// access. Only the account having no Agent Network at all reads as not +// configured. Providers stays caller-scoped either way — the endpoint on +// its own authorizes nothing, and the proxy still refuses every request +// no policy permits. +func (m *managerImpl) agentConfigForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.AgentConfig, error) { + notConfigured := &types.AgentConfig{Providers: []types.AgentConfigProvider{}} + + settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + switch { + case err == nil: + case isNotFound(err): + return notConfigured, nil + default: + return nil, fmt.Errorf("get agent network settings: %w", err) + } + if settings.Endpoint() == "" { + return notConfigured, nil + } + + authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, groupIDs) + if err != nil { + return nil, err + } + + out := &types.AgentConfig{ + Configured: true, + Endpoint: "https://" + settings.Endpoint(), + Providers: make([]types.AgentConfigProvider, 0, len(authorized)), + } + if len(authorized) == 0 { + return out, nil + } + + var guardrailsByID map[string]*types.Guardrail + if anyPolicyHasGuardrails(applicable) { + guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID) + if err != nil { + return nil, err + } + } + for _, p := range authorized { + allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID) + flavor := "" + if entry, ok := catalog.Lookup(p.ProviderID); ok { + flavor = entry.ParserID + } + out.Providers = append(out.Providers, types.AgentConfigProvider{ + Name: p.Name, + CatalogID: p.ProviderID, + APIFlavor: flavor, + AllModelsAllowed: allAllowed, + Models: models, + }) + } + return out, nil +} + +// authorizedProvidersForGroups returns the enabled providers referenced +// by at least one enabled policy whose source groups intersect groupIDs — +// the providers the caller's own policies authorize — in created_at order +// with ID tiebreak, the same deterministic order the router synthesizer +// presents. The applicable policies come back alongside so callers that +// need per-provider policy context (the setup's model computation) don't +// re-filter. Both the self-service setup answer and the caller-scoped +// provider list are built from this selection, so what the dashboard +// offers and what the proxy enforces never diverge. +func (m *managerImpl) authorizedProvidersForGroups(ctx context.Context, accountID string, groupIDs []string) ([]*types.Provider, []*types.Policy, error) { + policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, nil, fmt.Errorf("list account policies: %w", err) + } + applicable := filterPoliciesByGroups(policies, groupIDs) + if len(applicable) == 0 { + return nil, nil, nil + } + + providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, nil, fmt.Errorf("list account providers: %w", err) + } + + // filterEnabledProviders carries the enabled filter and the + // created_at/ID order shared with the router synthesizer. + enabled := filterEnabledProviders(providers) + authorized := make([]*types.Provider, 0, len(enabled)) + for _, p := range enabled { + if len(policiesForProvider(applicable, p.ID)) == 0 { + continue + } + authorized = append(authorized, p) + } + return authorized, applicable, nil +} + +// filterPoliciesByGroups returns the enabled policies whose SourceGroups +// intersect the caller's groups. Same group matching as +// filterApplicablePolicies, without the per-provider filter — the setup +// answer spans every provider the caller can reach. +func filterPoliciesByGroups(policies []*types.Policy, groupIDs []string) []*types.Policy { + groupSet := make(map[string]struct{}, len(groupIDs)) + for _, g := range groupIDs { + if g != "" { + groupSet[g] = struct{}{} + } + } + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if p == nil || !p.Enabled { + continue + } + if !anyGroupMatches(p.SourceGroups, groupSet) { + continue + } + out = append(out, p) + } + return out +} + +// policiesForProvider returns the subset of policies targeting the +// provider, order preserved. +func policiesForProvider(policies []*types.Policy, providerID string) []*types.Policy { + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if sliceContains(p.DestinationProviderIDs, providerID) { + out = append(out, p) + } + } + return out +} + +// effectiveModelsForProvider derives the caller's effective model set for +// one provider from the applicable policies that target it, mirroring +// policyPermitsModel: a policy with no allowlist-enabled guardrail is +// unrestricted, and one unrestricted policy makes the whole provider +// unrestricted (the proxy would admit any model through it). Otherwise +// the union of the policies' allowlists applies, intersected with the +// provider's declared models when the operator declared any — the router +// only claims declared models, so an allowlisted-but-undeclared model is +// unreachable and must not be advertised. With no declared models the +// router claims every model, so the allowlist union stands alone. +func effectiveModelsForProvider(provider *types.Provider, policies []*types.Policy, guardrailsByID map[string]*types.Guardrail) (bool, []string) { + restricted := true + union := make([]string, 0) + seen := make(map[string]struct{}) + for _, p := range policies { + policyRestricted := false + for _, gID := range p.GuardrailIDs { + g, ok := guardrailsByID[gID] + if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled { + continue + } + policyRestricted = true + for _, model := range g.Checks.ModelAllowlist.Models { + key := normaliseModelID(model) + if key == "" { + continue + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + union = append(union, key) + } + } + if !policyRestricted { + restricted = false + } + } + + declared := declaredModelIDs(provider) + if !restricted { + return true, declared + } + if len(provider.Models) == 0 { + // No operator declaration: the router claims every model, so the + // allowlist union is the effective set as-is. + return false, union + } + out := make([]string, 0, len(declared)) + for _, id := range declared { + // Compare through the canonical id the proxy's parser emits — a + // Bedrock declaration may carry the region/version form + // ("eu.anthropic.claude-...-v1:0") while the allowlist holds the + // stripped id the parser matches at request time, and the raw + // forms would never intersect. The declared id itself is what + // gets advertised, matching the router's route claim. + if _, ok := seen[normaliseModelID(normalizePricingModelID(provider.ProviderID, id))]; ok { + out = append(out, id) + } + } + return false, out +} + +// providerModelsByID maps effective model ids (as effectiveModelsForProvider +// returns them) back onto the operator's declared entries, keeping the +// declared casing and prices. With no operator declaration the ids are the +// allowlist union and have no declared entry to map to, so bare entries are +// synthesized — the router claims every model in that case, so those ids are +// reachable and belong in the answer. +func providerModelsByID(provider *types.Provider, ids []string) []types.ProviderModel { + if len(provider.Models) == 0 { + out := make([]types.ProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, types.ProviderModel{ID: id}) + } + return out + } + keep := make(map[string]struct{}, len(ids)) + for _, id := range ids { + keep[normaliseModelID(id)] = struct{}{} + } + out := make([]types.ProviderModel, 0, len(ids)) + for _, m := range provider.Models { + if _, ok := keep[normaliseModelID(m.ID)]; ok { + out = append(out, m) + } + } + return out +} + +// declaredModelIDs returns the models a provider exposes: the operator's +// curated list when present, otherwise the catalog entry's models (an +// empty operator list means "all catalog models"). Gateway/custom catalog +// entries declare no models, so the result may be empty. +func declaredModelIDs(provider *types.Provider) []string { + if ids := providerModelIDs(provider); len(ids) > 0 { + return ids + } + entry, ok := catalog.Lookup(provider.ProviderID) + if !ok { + return []string{} + } + out := make([]string, 0, len(entry.Models)) + for _, m := range entry.Models { + if m.ID != "" { + out = append(out, m.ID) + } + } + return out +} + +// GetAgentConfigForUser on the mock manager reports "not configured" so tests +// that don't care about setup still compile. +func (*mockManager) GetAgentConfigForUser(_ context.Context, _, _ string) (*types.AgentConfig, error) { + return &types.AgentConfig{Providers: []types.AgentConfigProvider{}}, nil +} diff --git a/management/internals/modules/agentnetwork/agent_config_realstore_test.go b/management/internals/modules/agentnetwork/agent_config_realstore_test.go new file mode 100644 index 000000000..9a66e1190 --- /dev/null +++ b/management/internals/modules/agentnetwork/agent_config_realstore_test.go @@ -0,0 +1,358 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" +) + +// These tests drive the effective-setup computation through the real +// sqlite store, mirroring the policyselect realstore suite: assert on +// observable answers (configured / providers / models), not on which +// store methods get called. The computation must agree with what the +// proxy enforces — policy filtering matches filterApplicablePolicies, +// model logic matches policyPermitsModel, and orphan providers are +// omitted like the router synthesizer omits them. + +func newAgentConfigTestMgr(t *testing.T) (*managerImpl, store.Store) { + t.Helper() + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + t.Cleanup(cleanup) + return &managerImpl{store: s}, s +} + +// newSetupTestGuardrail returns an allowlist-enabled guardrail. +func newSetupTestGuardrail(id string, models ...string) *types.Guardrail { + return &types.Guardrail{ + ID: id, + AccountID: testAccountID, + Name: "allowlist " + id, + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models}, + }, + } +} + +func TestAgentConfig_RealStore_NoSettingsRow(t *testing.T) { + mgr, _ := newAgentConfigTestMgr(t) + + setup, err := mgr.agentConfigForGroups(context.Background(), testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + assert.False(t, setup.Configured, "account without settings must read as not configured") + assert.Empty(t, setup.Endpoint) + assert.Empty(t, setup.Providers) +} + +func TestAgentConfig_RealStore_NoApplicablePolicy(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-other"}) + require.NoError(t, err) + assert.True(t, setup.Configured, "the account is set up, so every member reads as configured") + assert.Equal(t, "https://"+testEndpoint, setup.Endpoint, "every member gets the same connection config") + assert.Empty(t, setup.Providers, "a caller no policy covers is authorized for nothing") +} + +func TestAgentConfig_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + assert.True(t, setup.Configured) + assert.Equal(t, "https://"+testEndpoint, setup.Endpoint) + require.Len(t, setup.Providers, 1) + p := setup.Providers[0] + assert.Equal(t, "OpenAI", p.Name) + assert.Equal(t, "openai_api", p.CatalogID) + assert.Equal(t, "openai", p.APIFlavor) + assert.True(t, p.AllModelsAllowed, "policy without allowlist guardrail is unrestricted") + assert.Equal(t, []string{"gpt-5.4"}, p.Models, "declared models listed as a courtesy") +} + +func TestAgentConfig_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}} + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + // Allowlist admits gpt-5.4 (declared, odd casing/spacing) and gpt-4.1 + // (NOT declared — the router would never route it, so it must not be + // advertised). + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", " GPT-5.4 ", "gpt-4.1"))) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1"))) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + require.Len(t, setup.Providers, 1) + p := setup.Providers[0] + assert.False(t, p.AllModelsAllowed) + assert.Equal(t, []string{"gpt-5.4"}, p.Models, "allowlist ∩ declared, in declared order and casing") +} + +func TestAgentConfig_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + // A Bedrock operator typically declares the region/version form the + // vendor lists, while the allowlist holds the canonical id the proxy's + // parser emits at request time. The intersection must compare through + // the same normalization the parser applies, and the declared (raw) + // id is what gets advertised — it is what the router claims. + provider := newSynthTestProvider() + provider.ProviderID = "bedrock_api" + provider.Name = "Bedrock" + provider.Models = []types.ProviderModel{ + {ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, + {ID: "eu.amazon.nova-pro-v1:0"}, + } + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "anthropic.claude-sonnet-4-5"))) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1"))) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + require.Len(t, setup.Providers, 1) + p := setup.Providers[0] + assert.False(t, p.AllModelsAllowed) + assert.Equal(t, []string{"eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, p.Models, + "the allowlisted canonical id must admit the declared region/version form, and only it") +} + +func TestAgentConfig_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4"))) + restricted := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1") + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, restricted)) + open := newSynthTestPolicy(provider.ID, "grp-eng", "") + open.ID = "pol-2" + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, open)) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + require.Len(t, setup.Providers, 1) + assert.True(t, setup.Providers[0].AllModelsAllowed, + "one applicable policy without an allowlist makes the provider unrestricted — the proxy would admit any model through it") +} + +func TestAgentConfig_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}, {ID: "o4-mini"}} + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4"))) + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-2", "gpt-4o"))) + p1 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1") + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p1)) + p2 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-2") + p2.ID = "pol-2" + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p2)) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + require.Len(t, setup.Providers, 1) + p := setup.Providers[0] + assert.False(t, p.AllModelsAllowed) + assert.ElementsMatch(t, []string{"gpt-5.4", "gpt-4o"}, p.Models, "union of allowlists across applicable policies") +} + +func TestAgentConfig_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + // Orphan: enabled but referenced by no policy. + orphan := newSynthTestProvider() + orphan.ID = "prov-orphan" + require.NoError(t, s.SaveAgentNetworkProvider(ctx, orphan)) + // Disabled but referenced by an applicable policy. + disabled := newSynthTestProvider() + disabled.ID = "prov-disabled" + disabled.Enabled = false + require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(disabled.ID, "grp-eng", ""))) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + assert.True(t, setup.Configured) + assert.Empty(t, setup.Providers, "neither an orphan nor a disabled provider is reachable for the caller") +} + +func TestAgentConfig_RealStore_DisabledPolicyIgnored(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + policy.Enabled = false + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + assert.True(t, setup.Configured) + assert.Empty(t, setup.Providers, "a disabled policy authorizes nothing") +} + +func TestAgentConfig_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + // Gateway-style provider: no declared models — the router claims every + // model, so the allowlist union is the effective set on its own. + provider := newSynthTestProvider() + provider.ProviderID = "litellm_proxy" + provider.Name = "LiteLLM" + provider.Models = nil + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "claude-sonnet-4-5"))) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1"))) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + require.Len(t, setup.Providers, 1) + p := setup.Providers[0] + assert.False(t, p.AllModelsAllowed) + assert.Equal(t, []string{"claude-sonnet-4-5"}, p.Models) +} + +func TestAgentConfig_RealStore_ProvidersInCreatedAtOrder(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + newer := newSynthTestProvider() + newer.ID = "prov-newer" + newer.Name = "Newer" + newer.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + require.NoError(t, s.SaveAgentNetworkProvider(ctx, newer)) + older := newSynthTestProvider() + older.ID = "prov-older" + older.Name = "Older" + older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + require.NoError(t, s.SaveAgentNetworkProvider(ctx, older)) + + policy := newSynthTestPolicy(newer.ID, "grp-eng", "") + policy.DestinationProviderIDs = []string{newer.ID, older.ID} + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) + + setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"}) + require.NoError(t, err) + require.Len(t, setup.Providers, 2) + assert.Equal(t, "Older", setup.Providers[0].Name) + assert.Equal(t, "Newer", setup.Providers[1].Name) +} + +// TestGetAgentConfigForUser_RealStore pins the self-service entry point: the +// user's group memberships (AutoGroups — the same groups the user's peers +// carry) scope the providers, while the account's endpoint reaches every +// member — a user outside every policy gets the config with nothing +// authorized in it. +func TestGetAgentConfigForUser_RealStore(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + // users.account_id is a foreign key into accounts, enforced on + // MySQL/Postgres, so the account row must exist before its users. + require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID})) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"}, + })) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-other"}, + })) + + setupIn, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-in") + require.NoError(t, err) + assert.True(t, setupIn.Configured) + require.Len(t, setupIn.Providers, 1) + + setupOut, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-out") + require.NoError(t, err) + assert.True(t, setupOut.Configured, "the account is set up, so the user reads as configured") + assert.Equal(t, "https://"+testEndpoint, setupOut.Endpoint) + assert.Empty(t, setupOut.Providers, "user outside the policy's source groups is authorized for nothing") +} + +// TestGetUsageOverview_RealStore_SelfScoped pins the self-scope fallback: +// a caller without the account-wide usage grant gets the same aggregation +// the admin overview serves, but only ever their own rows — a user_id +// filter for someone else must be overridden, not honored, and never +// denied. A caller holding the grant keeps the account-wide view. +func TestGetUsageOverview_RealStore_SelfScoped(t *testing.T) { + mgr, s := newAgentConfigTestMgr(t) + mgr.permissionsManager = permissions.NewManager(s) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID})) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser, + })) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin, + })) + + own1 := newIngestTestEntry() + own1.ID, own1.UserId = "log-own-1", "user-a" + own2 := newIngestTestEntry() + own2.ID, own2.UserId = "log-own-2", "user-a" + other := newIngestTestEntry() + other.ID, other.UserId = "log-other", "user-b" + for _, e := range []*accesslogs.AccessLogEntry{own1, own2, other} { + require.NoError(t, IngestAccessLog(ctx, s, e)) + } + + otherID := "user-b" + filter := types.AgentNetworkAccessLogFilter{UserID: &otherID} + buckets, err := mgr.GetUsageOverview(ctx, testAccountID, "user-a", filter, types.ParseUsageGranularity("")) + require.NoError(t, err) + require.Len(t, buckets, 1, "same-day rows aggregate into one daily bucket") + assert.Equal(t, int64(200), buckets[0].InputTokens, "only the caller's two rows count — the foreign user_id filter is overridden") + assert.Equal(t, int64(100), buckets[0].OutputTokens) + + adminBuckets, err := mgr.GetUsageOverview(ctx, testAccountID, "admin", types.AgentNetworkAccessLogFilter{}, types.ParseUsageGranularity("")) + require.NoError(t, err) + require.Len(t, adminBuckets, 1) + assert.Equal(t, int64(300), adminBuckets[0].InputTokens, "the account-wide grant keeps the unscoped view") +} diff --git a/management/internals/modules/agentnetwork/handlers/agent_config_handler.go b/management/internals/modules/agentnetwork/handlers/agent_config_handler.go new file mode 100644 index 000000000..0d6c45110 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/agent_config_handler.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +// addAgentConfigEndpoints registers the self-service agent-config route. +// It is available to every authenticated user regardless of role: the +// providers in the response are scoped strictly to the caller, which is +// tighter than any role gate could be. The caller's own usage and requests are served by +// the regular usage/logs endpoints, which self-scope for callers without +// the account-wide grants. +func (h *handler) addAgentConfigEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/agent-config", h.getAgentConfig).Methods("GET", "OPTIONS") +} + +func (h *handler) getAgentConfig(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + setup, err := h.manager.GetAgentConfigForUser(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, agentConfigToAPI(setup)) +} + +func agentConfigToAPI(setup *types.AgentConfig) api.AgentNetworkAgentConfig { + providers := make([]api.AgentNetworkAgentConfigProvider, 0, len(setup.Providers)) + for _, p := range setup.Providers { + providers = append(providers, api.AgentNetworkAgentConfigProvider{ + Name: p.Name, + CatalogId: p.CatalogID, + ApiFlavor: p.APIFlavor, + AllModelsAllowed: p.AllModelsAllowed, + Models: p.Models, + }) + } + return api.AgentNetworkAgentConfig{ + Configured: setup.Configured, + Endpoint: setup.Endpoint, + Providers: providers, + } +} diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go index 645d1da61..ef4b93dac 100644 --- a/management/internals/modules/agentnetwork/handlers/providers_handler.go +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -46,6 +46,7 @@ func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) { h.addConsumptionEndpoints(router) h.addAccessLogEndpoints(router) h.addBudgetRuleEndpoints(router) + h.addAgentConfigEndpoints(router) } func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) { diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index 41789195e..98aca7f5d 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -85,6 +85,13 @@ type Manager interface { RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error RecordUsage(ctx context.Context, in RecordUsageInput) error SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error) + + // GetAgentConfigForUser backs the self-service agent-config endpoint. + // Caller-scoped, so it skips the role permission gate; see + // the implementation. The caller's own usage and requests come + // through GetUsageOverview / ListAccessLogs, which self-scope when + // the account-wide grant is missing. + GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error) } // PolicySelectionInput is the per-request selection envelope. The @@ -168,18 +175,124 @@ func NewManager( } } +// GetAllProviders returns the account's providers for callers holding the +// providers read grant (connection config redacted unless they can also +// update). A caller without the grant self-scopes instead of being denied +// — mirroring the usage and log endpoints: they get the providers their +// own policies authorize, redacted to the display surface, which is what +// feeds the dashboard's provider filter for plain users. func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) { - if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil { + ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return m.callerScopedProviders(ctx, accountID, userID) + } + providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) + if err != nil { return nil, err } - return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) + return m.redactProvidersForViewer(ctx, accountID, userID, providers) } +// GetProvider self-scopes like GetAllProviders: a caller without the read +// grant may fetch a provider their own policies authorize (redacted), and +// gets the same not-found answer for any other id — an out-of-scope +// provider must be indistinguishable from a nonexistent one. func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) { - if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil { + ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + scoped, err := m.callerScopedProviders(ctx, accountID, userID) + if err != nil { + return nil, err + } + for _, p := range scoped { + if p.ID == providerID { + return p, nil + } + } + return nil, status.NewAgentNetworkProviderNotFoundError(providerID) + } + provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID) + if err != nil { return nil, err } - return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID) + redacted, err := m.redactProvidersForViewer(ctx, accountID, userID, []*types.Provider{provider}) + if err != nil { + return nil, err + } + return redacted[0], nil +} + +// callerScopedProviders returns the providers the caller's own policies +// authorize — the same selection the self-service setup answer and the +// proxy's routing derive from — each reduced to the display surface. No +// role permission is needed: the answer is scoped strictly to the caller, +// and a caller outside every policy gets an empty list, indistinguishable +// from an account with nothing configured. +func (m *managerImpl) callerScopedProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) { + user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, user.AutoGroups) + if err != nil { + return nil, err + } + var guardrailsByID map[string]*types.Guardrail + if anyPolicyHasGuardrails(applicable) { + guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID) + if err != nil { + return nil, err + } + } + out := make([]*types.Provider, 0, len(authorized)) + for _, p := range authorized { + r := p.RedactedForViewer() + // The model list follows the same effective computation the setup + // answer and the proxy use: allowlist-restricted callers see only + // the models their guardrails permit, and an unrestricted policy + // on a provider without an operator declaration surfaces the + // catalog models, matching the setup response — so the dashboard's + // model filter never offers a model the caller's own requests + // could not use, and never comes up empty when the setup page + // lists models. Grant holders keep the full declared lists — + // their usage view spans everyone's requests. + _, effective := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID) + r.Models = providerModelsByID(p, effective) + out = append(out, r) + } + return out, nil +} + +// redactProvidersForViewer strips the connection configuration from +// providers handed to a caller who holds only the read grant on +// agent_network.providers. Update is the managing signal: a role that can +// edit a provider sees its config in the edit form anyway, while a +// read-only role (usage_viewer) only needs the display surface the usage +// filters resolve against — upstream URLs and operator-supplied header +// values are not part of that. Validation errors fail closed. +func (m *managerImpl) redactProvidersForViewer(ctx context.Context, accountID, userID string, providers []*types.Provider) ([]*types.Provider, error) { + canManage, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Update) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if canManage { + return providers, nil + } + out := make([]*types.Provider, 0, len(providers)) + for _, p := range providers { + if p == nil { + out = append(out, nil) + continue + } + out = append(out, p.RedactedForViewer()) + } + return out, nil } // DiscoverProviderModels asks the vendor which models a credential can reach. @@ -945,8 +1058,11 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str // ListAccessLogs returns a paginated, server-side-filtered page of // agent-network access logs plus the total count matching the filter. +// Callers without the account-wide logs grant get a self-scoped page — +// only their own requests — instead of a denial. func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) { - if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil { + filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter) + if err != nil { return nil, 0, err } return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter) @@ -954,18 +1070,23 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri // ListAccessLogSessions returns a paginated, server-side-filtered page of // agent-network access logs grouped by session, plus the total number of -// sessions matching the filter. +// sessions matching the filter. Self-scoped like ListAccessLogs for +// callers without the account-wide logs grant. func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { - if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil { + filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter) + if err != nil { return nil, 0, err } return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter) } // GetUsageOverview returns the filtered usage rows aggregated into time buckets -// at the requested granularity, oldest-first. +// at the requested granularity, oldest-first. Callers without the +// account-wide usage grant get their own rows aggregated instead of a +// denial, so the dashboard serves "my usage" from the same endpoint. func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) { - if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil { + filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkUsage, filter) + if err != nil { return nil, err } rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter) @@ -975,6 +1096,25 @@ func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID st return types.AggregateUsageByGranularity(rows, granularity), nil } +// scopeFilterToCaller applies the account-wide read gate for module and, +// when the caller lacks the grant, pins the filter to the caller instead +// of denying: their own user id replaces any requested one and group +// filters are dropped. A caller may always see their own rows — strictly +// tighter than any role gate — which is what lets every authenticated +// user read their usage and requests through the regular endpoints. +// Validation errors (not denials) still fail closed. +func (m *managerImpl) scopeFilterToCaller(ctx context.Context, accountID, userID string, module modules.Module, filter types.AgentNetworkAccessLogFilter) (types.AgentNetworkAccessLogFilter, error) { + ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, operations.Read) + if err != nil { + return filter, status.NewPermissionValidationError(err) + } + if !ok { + filter.UserID = &userID + filter.GroupIDs = nil + } + return filter, nil +} + // StartAccessLogCleanup launches a background sweep that periodically deletes // each account's agent-network access-log rows older than that account's // AccessLogRetentionDays. Usage records are never swept. A non-positive diff --git a/management/internals/modules/agentnetwork/provider_redaction_test.go b/management/internals/modules/agentnetwork/provider_redaction_test.go new file mode 100644 index 000000000..d6a749fb9 --- /dev/null +++ b/management/internals/modules/agentnetwork/provider_redaction_test.go @@ -0,0 +1,289 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// These tests pin the provider read surface per grant: a caller holding +// providers read together with update (managers) gets the full record, +// while read-only viewers (usage_viewer) get the display surface only — +// connection configuration is redacted before it reaches the wire layer. + +func TestGetAllProviders_RedactsConnectionConfigForReadOnlyViewer(t *testing.T) { + ctx := context.Background() + f := newBootstrapFixture(t) + + saved := newSynthTestProvider() + saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"} + saved.IdentityHeaderUserID = "X-User" + saved.IdentityHeaderGroups = "X-Groups" + saved.SkipTLSVerification = true + require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved)) + + f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true) + f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false) + + providers, err := f.manager.GetAllProviders(ctx, testAccountID, "viewer") + require.NoError(t, err) + require.Len(t, providers, 1) + p := providers[0] + assert.Equal(t, saved.ID, p.ID, "identity survives redaction") + assert.Equal(t, saved.Name, p.Name) + assert.Equal(t, saved.ProviderID, p.ProviderID) + assert.Equal(t, saved.Models, p.Models, "the model list backs the usage filters and stays") + assert.True(t, p.Enabled) + assert.Empty(t, p.UpstreamURL, "upstream URL is connection config") + assert.Empty(t, p.ExtraValues, "operator-typed header values are connection config") + assert.Empty(t, p.IdentityHeaderUserID) + assert.Empty(t, p.IdentityHeaderGroups) + assert.False(t, p.SkipTLSVerification) + assert.Empty(t, p.APIKey) + assert.Empty(t, p.SessionPrivateKey) + + stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, testAccountID, saved.ID) + require.NoError(t, err) + assert.NotEmpty(t, stored.UpstreamURL, "redaction must not write back to the store") +} + +func TestGetProvider_FullConfigForManagingCaller(t *testing.T) { + ctx := context.Background() + f := newBootstrapFixture(t) + + saved := newSynthTestProvider() + saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"} + require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved)) + + f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Read, true) + f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Update, true) + + p, err := f.manager.GetProvider(ctx, testAccountID, "admin", saved.ID) + require.NoError(t, err) + assert.Equal(t, saved.UpstreamURL, p.UpstreamURL, "a caller who can edit the provider sees its config") + assert.Equal(t, saved.ExtraValues, p.ExtraValues) +} + +func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) { + ctx := context.Background() + f := newBootstrapFixture(t) + + saved := newSynthTestProvider() + require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved)) + + f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true) + f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false) + + p, err := f.manager.GetProvider(ctx, testAccountID, "viewer", saved.ID) + require.NoError(t, err) + assert.Equal(t, saved.ID, p.ID) + assert.Empty(t, p.UpstreamURL) +} + +// The self-scope tests drive the real permissions manager over the real +// store, so role resolution is the production one: a plain user holds no +// providers grant and must fall back to the caller-scoped list — the same +// selection the self-service setup answer derives from — while an admin +// keeps the account-wide view with full config. + +// newSelfScopeStore seeds the account and its users only, so each test +// declares exactly the providers and policies it asserts on — the store +// rejects re-saving a policy id on MySQL, so tests never overwrite each +// other's rows. +func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) { + t.Helper() + mgr, s := newAgentConfigTestMgr(t) + mgr.permissionsManager = permissions.NewManager(s) + ctx := context.Background() + + require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID})) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"}, + })) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser, + })) + require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ + Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin, + })) + return mgr, s +} + +func newSelfScopeProvidersFixture(t *testing.T) (*managerImpl, store.Store) { + t.Helper() + mgr, s := newSelfScopeStore(t) + ctx := context.Background() + + granted := newSynthTestProvider() + granted.ID = "prov-granted" + granted.Name = "Granted" + require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted)) + + other := newSynthTestProvider() + other.ID = "prov-other" + other.Name = "Other" + other.CreatedAt = granted.CreatedAt.Add(time.Hour) + require.NoError(t, s.SaveAgentNetworkProvider(ctx, other)) + + disabled := newSynthTestProvider() + disabled.ID = "prov-disabled" + disabled.Name = "Disabled" + disabled.Enabled = false + require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled)) + + // user-a's group authorizes the granted and the disabled provider; the + // disabled one must still not surface (the proxy never routes it). + policy := newSynthTestPolicy(granted.ID, "grp-eng", "") + policy.DestinationProviderIDs = []string{granted.ID, disabled.ID} + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) + + return mgr, s +} + +func TestGetAllProviders_SelfScopedForPlainUser(t *testing.T) { + ctx := context.Background() + mgr, _ := newSelfScopeProvidersFixture(t) + + scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a") + require.NoError(t, err, "a caller without the read grant self-scopes instead of being denied") + require.Len(t, scoped, 1) + assert.Equal(t, "prov-granted", scoped[0].ID) + assert.Empty(t, scoped[0].UpstreamURL, "the caller-scoped list is the redacted display surface") + assert.NotEmpty(t, scoped[0].Models, "model list backs the dashboard filters") + + empty, err := mgr.GetAllProviders(ctx, testAccountID, "user-out") + require.NoError(t, err) + assert.Empty(t, empty, "a caller outside every policy gets an empty list, not an error") + + all, err := mgr.GetAllProviders(ctx, testAccountID, "admin") + require.NoError(t, err) + assert.Len(t, all, 3, "grant holders keep the account-wide list, disabled providers included") + for _, p := range all { + if p.ID == "prov-granted" { + assert.NotEmpty(t, p.UpstreamURL, "a managing caller sees the connection config") + } + } +} + +func TestGetProvider_SelfScopedForPlainUser(t *testing.T) { + ctx := context.Background() + mgr, _ := newSelfScopeProvidersFixture(t) + + p, err := mgr.GetProvider(ctx, testAccountID, "user-a", "prov-granted") + require.NoError(t, err) + assert.Equal(t, "prov-granted", p.ID) + assert.Empty(t, p.UpstreamURL) + + assertNotFound := func(id string) { + t.Helper() + _, err := mgr.GetProvider(ctx, testAccountID, "user-a", id) + require.Error(t, err) + var sErr *status.Error + require.ErrorAs(t, err, &sErr) + assert.Equal(t, status.NotFound, sErr.Type(), + "out-of-scope and nonexistent providers must be indistinguishable") + } + assertNotFound("prov-other") + assertNotFound("prov-disabled") + assertNotFound("prov-does-not-exist") +} + +func TestGetAllProviders_SelfScopedModelsFollowGuardrails(t *testing.T) { + ctx := context.Background() + mgr, s := newSelfScopeStore(t) + + // A provider declaring two models, restricted by an allowlist admitting + // one declared model plus one the operator never declared (unreachable — + // the router only claims declared models, so it must not surface). + granted := newSynthTestProvider() + granted.ID = "prov-models" + granted.Name = "Granted" + granted.Models = []types.ProviderModel{ + {ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02}, + {ID: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01}, + } + require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted)) + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-models", "gpt-5.4", "gpt-undeclared"))) + policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-models") + policy.ID = "pol-guard-models" + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) + + scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a") + require.NoError(t, err) + require.Len(t, scoped, 1) + require.Len(t, scoped[0].Models, 1, + "the self-scoped model list is the effective set: allowlist ∩ declared") + assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID) + assert.Equal(t, 0.004, scoped[0].Models[0].InputPer1k, "declared entry survives, prices included") + + all, err := mgr.GetAllProviders(ctx, testAccountID, "admin") + require.NoError(t, err) + for _, p := range all { + if p.ID == granted.ID { + assert.Len(t, p.Models, 2, + "grant holders keep the full declared list — their usage view spans everyone's requests") + } + } +} + +func TestGetAllProviders_SelfScopedAllowlistWithoutDeclaredModels(t *testing.T) { + ctx := context.Background() + mgr, s := newSelfScopeStore(t) + + // No operator declaration: the router claims every model, so the + // allowlist union is the effective set and comes back as bare entries. + granted := newSynthTestProvider() + granted.ID = "prov-bare" + granted.Name = "Granted" + granted.Models = nil + require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted)) + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-bare", "gpt-5.4"))) + policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-bare") + policy.ID = "pol-guard-bare" + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) + + scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a") + require.NoError(t, err) + require.Len(t, scoped, 1) + require.Len(t, scoped[0].Models, 1) + assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID) +} + +func TestGetAllProviders_SelfScopedUnrestrictedFallsBackToCatalogModels(t *testing.T) { + ctx := context.Background() + mgr, s := newSelfScopeStore(t) + + // Unrestricted policy on a provider without an operator declaration: + // the setup answer advertises the catalog models, and the scoped + // provider list must match so the model filter is never emptier than + // the setup page. + granted := newSynthTestProvider() + granted.ID = "prov-catalog" + granted.Name = "Granted" + granted.Models = nil + require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted)) + policy := newSynthTestPolicy(granted.ID, "grp-eng", "") + policy.ID = "pol-catalog" + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) + + scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a") + require.NoError(t, err) + require.Len(t, scoped, 1) + require.NotEmpty(t, scoped[0].Models, "catalog models back the filter when the operator declared none") + ids := make([]string, 0, len(scoped[0].Models)) + for _, m := range scoped[0].Models { + ids = append(ids, m.ID) + } + assert.Equal(t, declaredModelIDs(granted), ids, "the scoped list mirrors the setup answer's declared/catalog set") +} diff --git a/management/internals/modules/agentnetwork/types/agent_config.go b/management/internals/modules/agentnetwork/types/agent_config.go new file mode 100644 index 000000000..a154efed3 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/agent_config.go @@ -0,0 +1,42 @@ +package types + +// AgentConfig is the caller-scoped answer to "what may this caller +// use on the Agent Network?" — the account's proxy endpoint plus the +// providers and models the caller's groups authorize. It intentionally +// carries display metadata only: no keys, no upstream URLs, no policy or +// guardrail structure, and no hint of providers the caller cannot reach. +type AgentConfig struct { + // Configured is false only when the account has no Agent Network set + // up. A caller no policy covers yet still reads as configured, with an + // empty Providers list: every member gets the same connection config, + // and the empty list is what tells them to ask for access. + Configured bool + // Endpoint is the account's proxy base URL + // ("https://."), reachable over the NetBird tunnel + // only. Empty when Configured is false. Handing it to a member the + // policies do not cover authorizes nothing on its own — the proxy + // still refuses every request no policy permits. + Endpoint string + // Providers lists the providers at least one applicable policy + // authorizes for the caller, in the account's created_at order. + Providers []AgentConfigProvider +} + +// AgentConfigProvider is one authorized provider in an AgentConfig. +type AgentConfigProvider struct { + // Name is the operator-assigned label, e.g. "Bedrock prod". + Name string + // CatalogID names the catalog entry, e.g. "anthropic_api". + CatalogID string + // APIFlavor is the request-body shape the provider speaks — the + // catalog entry's parser id ("anthropic", "openai"); empty when the + // proxy dispatches the provider by URL path instead. + APIFlavor string + // AllModelsAllowed is true when no model allowlist restricts this + // provider for the caller. Models then lists the declared/catalog + // models as a courtesy (possibly none for gateway-style providers). + AllModelsAllowed bool + // Models is the effective model allowlist for the caller, or the + // declared/catalog models when AllModelsAllowed is true. + Models []string +} diff --git a/management/internals/modules/agentnetwork/types/provider.go b/management/internals/modules/agentnetwork/types/provider.go index b9a194bf6..145c63791 100644 --- a/management/internals/modules/agentnetwork/types/provider.go +++ b/management/internals/modules/agentnetwork/types/provider.go @@ -175,6 +175,26 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) { // ToAPIResponse renders the provider as the API representation. The API // key is intentionally never surfaced. +// RedactedForViewer returns a copy with the connection configuration +// blanked: upstream URL, operator-typed extra header values, identity +// header names, the TLS-verification override, and (defence in depth — +// they never reach the wire anyway) the sealed credentials. Read-only +// viewers such as usage_viewer only need the display surface — id, +// catalog id, name, enabled state, and the model list the usage filters +// resolve against — so their responses carry nothing about how the +// operator connects to the vendor. +func (p *Provider) RedactedForViewer() *Provider { + c := *p + c.UpstreamURL = "" + c.APIKey = "" + c.ExtraValues = nil + c.IdentityHeaderUserID = "" + c.IdentityHeaderGroups = "" + c.SkipTLSVerification = false + c.SessionPrivateKey = "" + return &c +} + func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { models := make([]api.AgentNetworkProviderModel, 0, len(p.Models)) for _, m := range p.Models { diff --git a/management/server/permissions/agent_network_roles_test.go b/management/server/permissions/agent_network_roles_test.go new file mode 100644 index 000000000..9ab708bd7 --- /dev/null +++ b/management/server/permissions/agent_network_roles_test.go @@ -0,0 +1,140 @@ +package permissions + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/permissions/roles" + "github.com/netbirdio/netbird/management/server/types" +) + +var allOps = []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete} + +// TestAgentNetworkAdminRole pins the delegated-admin contract: full control +// over the whole agent_network area (parent grant cascades to every +// submodule), read-only on the account objects needed to build policies, +// and nothing else in the account. +func TestAgentNetworkAdminRole(t *testing.T) { + manager := NewManager(nil) + ctx := context.Background() + + role, ok := roles.RolesMap[types.UserRoleAgentNetworkAdmin] + require.True(t, ok, "agent_network_admin must exist in RolesMap") + + agentNetworkModules := []modules.Module{ + modules.AgentNetwork, + modules.AgentNetworkProviders, + modules.AgentNetworkPolicies, + modules.AgentNetworkGuardrails, + modules.AgentNetworkBudgets, + modules.AgentNetworkUsage, + modules.AgentNetworkLogs, + modules.AgentNetworkSettings, + } + for _, m := range agentNetworkModules { + for _, op := range allOps { + assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), + "agent_network_admin must have %s on %s", op, m) + } + } + + // Settings read rides along because GET /api/accounts (which the + // dashboard needs to boot) validates it, like network_admin. + for _, m := range []modules.Module{modules.Users, modules.Groups, modules.Peers, modules.Accounts, modules.Settings} { + assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read), + "agent_network_admin must read %s to build policies and load the dashboard", m) + for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} { + assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), + "agent_network_admin must not have %s on %s", op, m) + } + } + + for _, m := range []modules.Module{modules.Networks, modules.Dns, modules.SetupKeys, modules.Routes} { + for _, op := range allOps { + assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), + "agent_network_admin must not have %s on %s", op, m) + } + } +} + +// TestUsageViewerRole pins the least-privilege cost role: read on the +// aggregated usage overview plus read-only on the resources its filters +// and display columns resolve against (users, groups, peers, the provider +// list) — no policies, no request-level logs (which can contain captured +// prompts), nothing else in the account. +func TestUsageViewerRole(t *testing.T) { + manager := NewManager(nil) + ctx := context.Background() + + role, ok := roles.RolesMap[types.UserRoleUsageViewer] + require.True(t, ok, "usage_viewer must exist in RolesMap") + + readOnly := []modules.Module{ + modules.AgentNetworkUsage, + modules.AgentNetworkProviders, + modules.Users, + modules.Groups, + modules.Peers, + } + for _, m := range readOnly { + assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read), + "usage_viewer must read %s for the usage view and its filters", m) + for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} { + assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), + "usage_viewer must not have %s on %s", op, m) + } + } + + denied := []modules.Module{ + modules.AgentNetwork, + modules.AgentNetworkPolicies, + modules.AgentNetworkGuardrails, + modules.AgentNetworkBudgets, + modules.AgentNetworkLogs, + modules.AgentNetworkSettings, + modules.Networks, + modules.SetupKeys, + } + for _, m := range denied { + for _, op := range allOps { + assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), + "usage_viewer must not have %s on %s", op, m) + } + } +} + +// TestBillingAdminRoleResolves pins that billing_admin has a proper entry +// in the permission map. Its plan/seat/invoice permissions are enforced +// outside this map; management-side it carries the regular User baseline +// instead of failing role resolution. +func TestBillingAdminRoleResolves(t *testing.T) { + manager := NewManager(nil) + ctx := context.Background() + + role, ok := roles.RolesMap[types.UserRoleBillingAdmin] + require.True(t, ok, "billing_admin must exist in RolesMap") + + permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleBillingAdmin) + require.NoError(t, err, "billing_admin role must resolve") + require.NotEmpty(t, permissions) + + for _, m := range []modules.Module{modules.AgentNetwork, modules.Networks, modules.Users, modules.Peers} { + for _, op := range allOps { + assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), + "billing_admin must not have %s on %s", op, m) + } + } +} + +// TestNewRolesParse pins the API role strings, which are permanent once +// released. +func TestNewRolesParse(t *testing.T) { + assert.Equal(t, types.UserRoleAgentNetworkAdmin, types.StrRoleToUserRole("agent_network_admin")) + assert.Equal(t, types.UserRoleUsageViewer, types.StrRoleToUserRole("usage_viewer")) + assert.Equal(t, types.UserRoleBillingAdmin, types.StrRoleToUserRole("billing_admin")) +} diff --git a/management/server/permissions/roles/agent_network_admin.go b/management/server/permissions/roles/agent_network_admin.go new file mode 100644 index 000000000..0f48500ec --- /dev/null +++ b/management/server/permissions/roles/agent_network_admin.go @@ -0,0 +1,62 @@ +package roles + +import ( + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/types" +) + +// AgentNetworkAdmin is the delegated administrator for the Agent Network +// area: full control over providers, policies, guardrails, budgets, usage, +// logs, and its settings, plus read-only visibility into the account +// objects needed to build policies (users, groups, peers) and the account +// settings/meta read the dashboard needs to boot (GET /api/accounts +// validates Settings read, same as network_admin). Nothing else in the +// account is visible. +var AgentNetworkAdmin = RolePermissions{ + Role: types.UserRoleAgentNetworkAdmin, + AutoAllowNew: map[operations.Operation]bool{ + operations.Read: false, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + Permissions: Permissions{ + modules.AgentNetwork: { + operations.Read: true, + operations.Create: true, + operations.Update: true, + operations.Delete: true, + }, + modules.Users: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + modules.Groups: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + modules.Peers: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + modules.Accounts: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + modules.Settings: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + }, +} diff --git a/management/server/permissions/roles/billing_admin.go b/management/server/permissions/roles/billing_admin.go new file mode 100644 index 000000000..22597b587 --- /dev/null +++ b/management/server/permissions/roles/billing_admin.go @@ -0,0 +1,20 @@ +package roles + +import ( + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/types" +) + +// BillingAdmin manages plans, seats, and invoices, which are enforced +// outside this permission map (NetBird Cloud). Management-side it carries +// the regular User baseline; the explicit entry keeps role resolution from +// failing with a role-not-found error. +var BillingAdmin = RolePermissions{ + Role: types.UserRoleBillingAdmin, + AutoAllowNew: map[operations.Operation]bool{ + operations.Read: false, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, +} diff --git a/management/server/permissions/roles/role_permissions.go b/management/server/permissions/roles/role_permissions.go index 754e568f5..517f07ff3 100644 --- a/management/server/permissions/roles/role_permissions.go +++ b/management/server/permissions/roles/role_permissions.go @@ -15,9 +15,12 @@ type RolePermissions struct { type Permissions map[modules.Module]map[operations.Operation]bool var RolesMap = map[types.UserRole]RolePermissions{ - types.UserRoleOwner: Owner, - types.UserRoleAdmin: Admin, - types.UserRoleUser: User, - types.UserRoleAuditor: Auditor, - types.UserRoleNetworkAdmin: NetworkAdmin, + types.UserRoleOwner: Owner, + types.UserRoleAdmin: Admin, + types.UserRoleUser: User, + types.UserRoleAuditor: Auditor, + types.UserRoleNetworkAdmin: NetworkAdmin, + types.UserRoleAgentNetworkAdmin: AgentNetworkAdmin, + types.UserRoleUsageViewer: UsageViewer, + types.UserRoleBillingAdmin: BillingAdmin, } diff --git a/management/server/permissions/roles/usage_viewer.go b/management/server/permissions/roles/usage_viewer.go new file mode 100644 index 000000000..e480ae478 --- /dev/null +++ b/management/server/permissions/roles/usage_viewer.go @@ -0,0 +1,60 @@ +package roles + +import ( + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/types" +) + +// UsageViewer is the regular User baseline plus read access to the +// aggregated Agent Network usage and cost overview, and read-only access +// to the resources the usage filters and display columns resolve against: +// users and groups (identity filters and name resolution), peers (agent +// principals in the caller column), and the provider list (provider and +// model filter options — the manager redacts connection config such as +// upstream URLs and operator-supplied header values for callers holding +// read without update). It sees no policies and no account-wide +// request-level access logs (which can contain captured prompts); its own +// requests remain readable through the self-scoped endpoints, like any +// caller's. +var UsageViewer = RolePermissions{ + Role: types.UserRoleUsageViewer, + AutoAllowNew: map[operations.Operation]bool{ + operations.Read: false, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + Permissions: Permissions{ + modules.AgentNetworkUsage: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + modules.AgentNetworkProviders: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + modules.Users: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + modules.Groups: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + modules.Peers: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, + }, +} diff --git a/management/server/types/user.go b/management/server/types/user.go index 2e975809c..02358ebc2 100644 --- a/management/server/types/user.go +++ b/management/server/types/user.go @@ -11,13 +11,15 @@ import ( ) const ( - UserRoleOwner UserRole = "owner" - UserRoleAdmin UserRole = "admin" - UserRoleUser UserRole = "user" - UserRoleUnknown UserRole = "unknown" - UserRoleBillingAdmin UserRole = "billing_admin" - UserRoleAuditor UserRole = "auditor" - UserRoleNetworkAdmin UserRole = "network_admin" + UserRoleOwner UserRole = "owner" + UserRoleAdmin UserRole = "admin" + UserRoleUser UserRole = "user" + UserRoleUnknown UserRole = "unknown" + UserRoleBillingAdmin UserRole = "billing_admin" + UserRoleAuditor UserRole = "auditor" + UserRoleNetworkAdmin UserRole = "network_admin" + UserRoleAgentNetworkAdmin UserRole = "agent_network_admin" + UserRoleUsageViewer UserRole = "usage_viewer" UserStatusActive UserStatus = "active" UserStatusDisabled UserStatus = "disabled" @@ -42,6 +44,10 @@ func StrRoleToUserRole(strRole string) UserRole { return UserRoleAuditor case "network_admin": return UserRoleNetworkAdmin + case "agent_network_admin": + return UserRoleAgentNetworkAdmin + case "usage_viewer": + return UserRoleUsageViewer default: return UserRoleUnknown } @@ -140,7 +146,7 @@ func (u *User) IsRegularUser() bool { // IsRestrictable checks whether a user is in a restrictable role. func (u *User) IsRestrictable() bool { - return u.Role == UserRoleUser || u.Role == UserRoleBillingAdmin + return u.Role == UserRoleUser || u.Role == UserRoleBillingAdmin || u.Role == UserRoleUsageViewer } // ToUserInfo converts a User object to a UserInfo object. diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 142d9a562..a7eca856d 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5815,6 +5815,57 @@ components: required: - name - checks + AgentNetworkAgentConfig: + type: object + description: The caller-scoped Agent Network connection config backing the self-service "Connect your agent" view. Available to every authenticated user; the providers are computed from the caller's own groups and the answer carries display metadata only. + properties: + configured: + type: boolean + description: False only when the account has no Agent Network set up. A caller that no policy covers yet still reads as configured, with an empty providers list. + endpoint: + type: string + description: The account's Agent Network base URL, reachable over the NetBird tunnel only. Returned to every member of a configured account - it authorizes nothing on its own, since the gateway still refuses every request no policy permits. Empty when configured is false. + example: https://calm-otter.proxy.example.com + providers: + type: array + description: The providers at least one of the caller's policies authorizes, in creation order. Empty when no policy covers the caller. + items: + $ref: '#/components/schemas/AgentNetworkAgentConfigProvider' + required: + - configured + - endpoint + - providers + AgentNetworkAgentConfigProvider: + type: object + description: One provider the caller may use, reduced to what a local tool needs for configuration. + properties: + name: + type: string + description: Operator-assigned provider label. + example: Bedrock prod + catalog_id: + type: string + description: Catalog entry id naming the provider type. + example: bedrock_api + api_flavor: + type: string + description: Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead. + example: anthropic + all_models_allowed: + type: boolean + description: True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy. + models: + type: array + description: The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true). + items: + type: string + example: [ "anthropic.claude-sonnet-4-5" ] + required: + - name + - catalog_id + - api_flavor + - all_models_allowed + - models AgentNetworkConsumption: type: object description: One per-(dimension, window) consumption counter row. The proxy ticks one row per dimension on every served LLM request; the dashboard reads this listing to surface live counter growth. @@ -13479,7 +13530,7 @@ paths: /api/agent-network/access-logs: get: summary: List Agent Network access logs - description: Returns a paginated, server-side-filtered list of agent-network (LLM) access log entries. Available only when the account has log collection enabled; otherwise entries are not retained. + description: Returns a paginated, server-side-filtered list of agent-network (LLM) access log entries. Available only when the account has log collection enabled; otherwise entries are not retained. Callers without the account-wide grant are not denied - the response is scoped to their own requests (any user_id or group_id filter is overridden). tags: [ Agent Network ] security: - BearerAuth: [ ] @@ -13594,7 +13645,7 @@ paths: /api/agent-network/access-log-sessions: get: summary: List Agent Network access logs grouped by session - description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled. + description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled. Callers without the account-wide grant are not denied - the response is scoped to their own requests (any user_id or group_id filter is overridden). tags: [ Agent Network ] security: - BearerAuth: [ ] @@ -13709,7 +13760,7 @@ paths: /api/agent-network/usage/overview: get: summary: Agent Network usage overview - description: Returns agent-network token and cost usage aggregated into time buckets, server-side filtered. Usage is always collected (independent of log collection). + description: Returns agent-network token and cost usage aggregated into time buckets, server-side filtered. Usage is always collected (independent of log collection). Callers without the account-wide grant are not denied - the response is scoped to their own usage (any user_id or group_id filter is overridden). tags: [ Agent Network ] security: - BearerAuth: [ ] @@ -13809,6 +13860,25 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" + /api/agent-network/agent-config: + get: + summary: Retrieve the caller's Agent Network agent config + description: Returns everything the caller needs to configure a local AI tool and nothing more - the account's Agent Network endpoint plus the providers and models the caller's own policies allow. Available to every authenticated user regardless of role; the response never contains provider credentials, policy or guardrail configuration, or providers the caller cannot reach. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: The caller-scoped Agent Network agent config + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkAgentConfig' + '401': + "$ref": "#/components/responses/requires_authentication" + '500': + "$ref": "#/components/responses/internal_error" /api/agent-network/settings: get: summary: Retrieve Agent Network settings diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 3fc3c4ef3..74e10f1b5 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1931,6 +1931,36 @@ type AgentNetworkAccessLogsResponse struct { TotalRecords int `json:"total_records"` } +// AgentNetworkAgentConfig The caller-scoped Agent Network connection config backing the self-service "Connect your agent" view. Available to every authenticated user; the providers are computed from the caller's own groups and the answer carries display metadata only. +type AgentNetworkAgentConfig struct { + // Configured False only when the account has no Agent Network set up. A caller that no policy covers yet still reads as configured, with an empty providers list. + Configured bool `json:"configured"` + + // Endpoint The account's Agent Network base URL, reachable over the NetBird tunnel only. Returned to every member of a configured account - it authorizes nothing on its own, since the gateway still refuses every request no policy permits. Empty when configured is false. + Endpoint string `json:"endpoint"` + + // Providers The providers at least one of the caller's policies authorizes, in creation order. Empty when no policy covers the caller. + Providers []AgentNetworkAgentConfigProvider `json:"providers"` +} + +// AgentNetworkAgentConfigProvider One provider the caller may use, reduced to what a local tool needs for configuration. +type AgentNetworkAgentConfigProvider struct { + // AllModelsAllowed True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy. + AllModelsAllowed bool `json:"all_models_allowed"` + + // ApiFlavor Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead. + ApiFlavor string `json:"api_flavor"` + + // CatalogId Catalog entry id naming the provider type. + CatalogId string `json:"catalog_id"` + + // Models The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true). + Models []string `json:"models"` + + // Name Operator-assigned provider label. + Name string `json:"name"` +} + // AgentNetworkBudgetRule Account-level budget rule. A limit-only rule bound to groups and/or users that applies across all policies as a min-wins ceiling. Empty targets means it applies to every caller. type AgentNetworkBudgetRule struct { CreatedAt *time.Time `json:"created_at,omitempty"` From ebc259e30b42e98f46952e9f61f80a09c6e4432f Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 1 Sep 2026 17:53:41 +0200 Subject: [PATCH 32/40] [management,client] Gate remote jobs behind an admin opt-in with MDM support (#7153) This introduces a disabled-by-default allow-remote-jobs setting that controls whether the management server may run jobs (such as debug bundles) on a peer. The flag propagates end to end: through client configuration, the daemon SetConfig and Login requests, authentication, and system info, up to management, where it is stored on the peer and exposed on the peers API as remote_jobs_allowed. The client refuses any management-requested job unless the peer has opted in. Because enabling remote jobs crosses the user-to-root boundary, turning it on requires privilege, mirroring the SSH-server gate. Administrators can enforce the setting through MDM policy on both macOS and Windows, and MDM can also override the debug-bundle upload URL. The change ships policy documentation and generated profile templates, and adds configuration, conflict, and enforcement tests covering the opt-in, privilege, and MDM paths. --- client/cmd/jobs.go | 13 + client/cmd/up.go | 14 + client/internal/auth/auth.go | 1 + client/internal/connect.go | 2 + client/internal/debug/debug.go | 3 + client/internal/debug/debug_test.go | 22 +- client/internal/engine.go | 43 +- client/internal/engine_bundle_test.go | 1 + client/internal/profilemanager/config.go | 83 +- client/internal/profilemanager/config_test.go | 78 + client/mdm/canonical_loaders.go | 2 + client/mdm/policy.go | 13 + client/proto/daemon.pb.go | 57 +- client/proto/daemon.proto | 8 + client/server/mdm.go | 4 + client/server/server.go | 3 + client/server/setconfig_test.go | 6 + client/server/ssh_gate.go | 12 + client/server/ssh_gate_test.go | 28 + client/system/info.go | 5 + docs/io.netbird.client.plist | 15 + docs/netbird-macos.mobileconfig | 13 + docs/netbird-macos.sh | 61 +- docs/netbird-policy.reg | Bin 1558 -> 1732 bytes docs/netbird.adml | 12 + docs/netbird.admx | 25 + e2e/harness/client.go | 23 +- e2e/remotejobs/main_test.go | 47 + e2e/remotejobs/remotejobs_test.go | 197 ++ management/internals/shared/grpc/server.go | 1 + .../http/handlers/peers/peers_handler.go | 2 + .../testing/testing_tools/channel/channel.go | 40 +- management/server/peer/peer.go | 2 + management/server/store/sql_store_test.go | 2 +- shared/management/client/grpc.go | 1 + shared/management/http/api/openapi.yml | 4 + shared/management/http/api/types.gen.go | 3 + shared/management/proto/management.pb.go | 1944 +++++++++-------- shared/management/proto/management.proto | 5 + 39 files changed, 1770 insertions(+), 1025 deletions(-) create mode 100644 client/cmd/jobs.go create mode 100644 e2e/remotejobs/main_test.go create mode 100644 e2e/remotejobs/remotejobs_test.go diff --git a/client/cmd/jobs.go b/client/cmd/jobs.go new file mode 100644 index 000000000..36aab3570 --- /dev/null +++ b/client/cmd/jobs.go @@ -0,0 +1,13 @@ +package cmd + +// remoteJobsAllowedFlag opts this peer into running remote jobs (e.g. debug +// bundles) requested by the management server. It defaults to false: remote +// jobs are an explicit opt-in, and enabling it is a privileged change (see the +// daemon gate in client/server), mirroring the SSH server opt-in. +const remoteJobsAllowedFlag = "allow-remote-jobs" + +var remoteJobsAllowed bool + +func init() { + upCmd.PersistentFlags().BoolVar(&remoteJobsAllowed, remoteJobsAllowedFlag, false, "Allow the management server to run remote jobs (e.g. debug bundles) on this peer") +} diff --git a/client/cmd/up.go b/client/cmd/up.go index 9cf5eea26..2e53224df 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -428,6 +428,17 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ return nil } +// setBoolPtrIfChanged points dst at a copy of val when the named bool flag was +// explicitly set on cmd. It collapses the repeated +// "if cmd.Flag(x).Changed { field = &val }" pattern in the request builders into +// a single call, keeping their cognitive complexity within bounds. +func setBoolPtrIfChanged(cmd *cobra.Command, name string, dst **bool, val bool) { + if cmd.Flag(name).Changed { + dst2 := val + *dst = &dst2 + } +} + // setSSHSetConfigFields copies the SSH server flags the user actually // passed into req, leaving the rest unset so the daemon keeps the // persisted values. @@ -477,6 +488,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.RosenpassPermissive = &rosenpassPermissive } setSSHSetConfigFields(&req, cmd) + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &req.RemoteJobsAllowed, remoteJobsAllowed) if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { @@ -568,6 +580,7 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil if cmd.Flag(serverSSHAllowedFlag).Changed { ic.ServerSSHAllowed = &serverSSHAllowed } + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &ic.RemoteJobsAllowed, remoteJobsAllowed) if cmd.Flag(enableSSHRootFlag).Changed { ic.EnableSSHRoot = &enableSSHRoot @@ -727,6 +740,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte } setSSHLoginFields(&loginRequest, cmd) + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &loginRequest.RemoteJobsAllowed, remoteJobsAllowed) if cmd.Flag(disableAutoConnectFlag).Changed { loginRequest.DisableAutoConnect = &autoConnectDisabled diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index b3a9e1158..939df3a21 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -368,6 +368,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) { a.config.EnableSSHLocalPortForwarding, a.config.EnableSSHRemotePortForwarding, a.config.DisableSSHAuth, + a.config.RemoteJobsAllowed, ) } diff --git a/client/internal/connect.go b/client/internal/connect.go index 08bd84f0c..88d829d2f 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -652,6 +652,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf RosenpassEnabled: config.RosenpassEnabled, RosenpassPermissive: config.RosenpassPermissive, ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed), + RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(config.RemoteJobsAllowed), EnableSSHRoot: config.EnableSSHRoot, EnableSSHSFTP: config.EnableSSHSFTP, EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding, @@ -749,6 +750,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config.EnableSSHLocalPortForwarding, config.EnableSSHRemotePortForwarding, config.DisableSSHAuth, + config.RemoteJobsAllowed, ) return client.Login(sysInfo, pubSSHKey, config.DNSLabels) } diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 7bb71c53b..b362ae293 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -711,6 +711,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) if g.internalConfig.ServerSSHAllowed != nil { configContent.WriteString(fmt.Sprintf("ServerSSHAllowed: %v\n", *g.internalConfig.ServerSSHAllowed)) } + if g.internalConfig.RemoteJobsAllowed != nil { + configContent.WriteString(fmt.Sprintf("RemoteJobsAllowed: %v\n", *g.internalConfig.RemoteJobsAllowed)) + } if g.internalConfig.EnableSSHRoot != nil { configContent.WriteString(fmt.Sprintf("EnableSSHRoot: %v\n", *g.internalConfig.EnableSSHRoot)) } diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 7fe93a5c1..17d520358 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -839,12 +839,13 @@ COMMIT` // the excluded set with a justification. func TestAddConfig_AllFieldsCovered(t *testing.T) { excluded := map[string]string{ - "PrivateKey": "sensitive: WireGuard private key", - "PreSharedKey": "sensitive: WireGuard pre-shared key", - "SSHKey": "sensitive: SSH private key", - "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", - "Name": "non-config: profile name is not needed for debug purposes", - "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", + "PrivateKey": "sensitive: WireGuard private key", + "PreSharedKey": "sensitive: WireGuard pre-shared key", + "SSHKey": "sensitive: SSH private key", + "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", + "Name": "non-config: profile name is not needed for debug purposes", + "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", + "DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle", } mURL, _ := url.Parse("https://api.example.com:443") @@ -864,6 +865,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { RosenpassEnabled: true, RosenpassPermissive: true, ServerSSHAllowed: &bTrue, + RemoteJobsAllowed: &bTrue, EnableSSHRoot: &bTrue, EnableSSHSFTP: &bTrue, EnableSSHLocalPortForwarding: &bTrue, @@ -886,6 +888,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { ClientCertPath: "/tmp/cert", ClientCertKeyPath: "/tmp/key", LazyConnection: "on", + DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret", MTU: 1280, DisableIPv6: true, SyncMessageVersion: func(v int) *int { return &v }(1), @@ -903,6 +906,13 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { g.addCommonConfigFields(&sb) rendered := sb.String() + renderAddConfigSpecific(g) + // DebugBundleUploadURL is an MDM-provided value that can carry + // credentials or signed query tokens. It is deliberately excluded + // above; assert it never reaches the rendered bundle — neither the + // field name nor the token — in either anonymize mode. + assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle") + assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle") + val := reflect.ValueOf(cfg).Elem() typ := val.Type() var missing []string diff --git a/client/internal/engine.go b/client/internal/engine.go index 0cbf32fce..2cfd19a81 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -137,6 +137,7 @@ type EngineConfig struct { RosenpassPermissive bool ServerSSHAllowed bool + RemoteJobsAllowed bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -1259,6 +1260,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) { e.config.EnableSSHLocalPortForwarding, e.config.EnableSSHRemotePortForwarding, e.config.DisableSSHAuth, + &e.config.RemoteJobsAllowed, ) } @@ -1344,6 +1346,13 @@ func (e *Engine) receiveJobEvents() { ID: msg.ID, Status: mgmProto.JobStatus_failed, } + // Remote jobs are an explicit opt-in. When not enabled on this + // peer, every job is refused before any work is done. + if !e.config.RemoteJobsAllowed { + log.Warnf("refusing remote job: remote jobs are not enabled on this peer (enable with --allow-remote-jobs)") + resp.Reason = []byte("remote jobs are not enabled on this peer") + return &resp + } switch params := msg.WorkloadParameters.(type) { case *mgmProto.JobRequest_Bundle: bundleResult, err := e.handleBundle(params.Bundle) @@ -1380,7 +1389,15 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR params.GetAnonymize(), params.GetAnonymizeLevel(), params.GetLogFileCount(), params.GetBundleFor(), params.GetBundleForTime()) log.Debugf("remote debug bundle request parameters: %s", params.String()) - if err := validateBundleUploadURL(params.GetUploadUrl()); err != nil { + // Resolve the upload destination: an MDM override, when set, takes + // precedence over the management-supplied URL. Both are validated the same + // way; an empty result falls back to the default upload server downstream. + uploadURL := params.GetUploadUrl() + if override := e.config.ProfileConfig.DebugBundleUploadURL; override != "" { + log.Infof("using MDM debug bundle upload URL override instead of the management-supplied value") + uploadURL = override + } + if err := validateBundleUploadURL(uploadURL); err != nil { return nil, err } @@ -1411,7 +1428,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR waitFor := time.Duration(params.BundleForTime) * time.Minute - uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), params.GetUploadUrl()) + uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), uploadURL) if err != nil { return nil, err } @@ -1425,23 +1442,13 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR } // validateBundleUploadURL sanity-checks a management-supplied upload URL for a -// remote debug bundle job. An empty value is accepted — the executor falls back -// to the default upload service. A non-empty value must be a well-formed https -// URL with a host; a malformed value or a plaintext scheme is rejected. This -// deliberately does not constrain which host may receive the bundle; that -// policy is left open pending a decision on management-directed uploads. +// remote debug bundle job. It delegates to profilemanager.ValidateBundleUploadURL +// so the executor and the MDM policy override share one definition of the rule +// (empty accepted; otherwise a well-formed https URL with a host) and cannot +// drift. The host is deliberately left unconstrained pending a decision on +// management-directed uploads. func validateBundleUploadURL(raw string) error { - if raw == "" { - return nil - } - parsed, err := url.Parse(raw) - if err != nil { - return fmt.Errorf("parse upload URL: %w", err) - } - if parsed.Scheme != "https" || parsed.Host == "" { - return fmt.Errorf("upload URL must be an https URL with a host") - } - return nil + return profilemanager.ValidateBundleUploadURL(raw) } // receiveManagementEvents connects to the Management Service event stream to receive updates from the management service diff --git a/client/internal/engine_bundle_test.go b/client/internal/engine_bundle_test.go index d736e2591..20b40a8a6 100644 --- a/client/internal/engine_bundle_test.go +++ b/client/internal/engine_bundle_test.go @@ -20,6 +20,7 @@ func TestValidateBundleUploadURL(t *testing.T) { {name: "https self-hosted host", raw: "https://upload.example.com"}, {name: "plaintext rejected", raw: "http://upload.example.com", wantErr: true}, {name: "missing host rejected", raw: "https:///upload", wantErr: true}, + {name: "port-only authority rejected", raw: "https://:443", wantErr: true}, {name: "non-url scheme rejected", raw: "ftp://upload.example.com", wantErr: true}, {name: "garbage rejected", raw: "://not a url", wantErr: true}, } { diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index e83cb4015..10c1758d1 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -70,6 +70,7 @@ type ConfigInput struct { StateFilePath string PreSharedKey *string ServerSSHAllowed *bool + RemoteJobsAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -127,6 +128,7 @@ type Config struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed *bool + RemoteJobsAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -192,6 +194,12 @@ type Config struct { // Runtime-only: re-derived from MDM policy on each load, never persisted. LazyConnection string `json:"-"` + // DebugBundleUploadURL is the MDM-managed debug-bundle upload URL override. + // When set, it takes precedence over the management-supplied upload URL for + // remote debug bundle jobs. Runtime-only: re-derived from MDM policy on each + // load, never persisted. + DebugBundleUploadURL string `json:"-"` + MTU uint16 // policy is the MDM policy that produced the currently-set values for @@ -289,7 +297,10 @@ func createNewConfig(input ConfigInput) (*Config, error) { config := &Config{ // defaults to false only for new (post 0.26) configurations ServerSSHAllowed: util.False(), - WgPort: iface.DefaultWgPort, + // Remote jobs are an explicit opt-in and default off, including for + // legacy configs (a nil value materializes to false at connect time). + RemoteJobsAllowed: util.False(), + WgPort: iface.DefaultWgPort, } if _, err := config.apply(input); err != nil { @@ -492,6 +503,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) { + if *input.RemoteJobsAllowed { + log.Infof("enabling remote jobs") + } else { + log.Infof("disabling remote jobs") + } + config.RemoteJobsAllowed = input.RemoteJobsAllowed + updated = true + } else if config.RemoteJobsAllowed == nil { + // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config + // with no value defaults to disabled rather than being turned on. + config.RemoteJobsAllowed = util.False() + updated = true + } + if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") @@ -701,6 +727,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { // for the key, so per-field rejection of user writes still applies). func (config *Config) applyMDMPolicy(policy *mdm.Policy) { config.policy = policy + + // DebugBundleUploadURL is a runtime-only override re-derived from MDM on + // every apply. Resolve it unconditionally (before the IsEmpty early return) + // so a policy that drops the key, becomes empty, or carries an invalid + // value can never leave a previously-enforced upload target active on a + // reused Config instance. + config.DebugBundleUploadURL = mdmDebugBundleUploadURL(policy) + if policy.IsEmpty() { return } @@ -748,6 +782,7 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { } applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv }) + applyBool(mdm.KeyRemoteJobsAllowed, func(v bool) { bv := v; config.RemoteJobsAllowed = &bv }) applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v }) applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v }) applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v }) @@ -781,6 +816,52 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { config.LazyConnection = state logApplied(mdm.KeyLazyConnection, state) } + +} + +// ValidateBundleUploadURL sanity-checks a debug-bundle upload URL. An empty +// value is accepted — the executor falls back to the default upload service. A +// non-empty value must be a well-formed https URL with a host; a malformed +// value or a plaintext scheme is rejected. It deliberately does not constrain +// which host may receive the bundle. This is the single source of truth for the +// rule, shared by the remote-job executor (client/internal) and the MDM policy +// override below so the two validation paths cannot drift. +func ValidateBundleUploadURL(raw string) error { + if raw == "" { + return nil + } + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse upload URL: %w", err) + } + // Hostname(), not Host: an authority like ":443" is non-empty but has no + // host, and would fail the actual upload. + if parsed.Scheme != "https" || parsed.Hostname() == "" { + return fmt.Errorf("upload URL must be an https URL with a host") + } + return nil +} + +// mdmDebugBundleUploadURL resolves the MDM-enforced debug-bundle upload URL +// override from the policy, returning the empty string when the policy does +// not carry a valid KeyBundleUploadURL. An absent or invalid value fails +// closed to "" so it falls back to the management-supplied or default upload +// target rather than a previously-enforced one. The URL is never logged: it +// can embed credentials or signed query tokens (KeyBundleUploadURL is in +// mdm.SecretKeys). +func mdmDebugBundleUploadURL(policy *mdm.Policy) string { + v, ok := policy.GetString(mdm.KeyBundleUploadURL) + if !ok || v == "" { + return "" + } + // Must be a well-formed https URL with a host, matching the client's + // remote-job upload-URL validation (shared validator, single source of truth). + if err := ValidateBundleUploadURL(v); err != nil { + log.Warnf("MDM debug bundle upload URL is invalid (must be an https URL with a host); ignoring the override") + return "" + } + log.Infof("MDM override %s = ********** (secret)", mdm.KeyBundleUploadURL) + return v } // parseURL parses and validates the URL for the named service. The URL diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index 736ff3412..248920b5e 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -14,6 +14,7 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/internal/routemanager/dynamic" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/util" ) @@ -271,6 +272,83 @@ func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) { } } +func TestUpdateConfigRemoteJobsAllowed(t *testing.T) { + // Unlike SSH (which defaults on for legacy configs), remote jobs are an + // explicit opt-in: a pre-existing config with no value materializes to off. + t.Run("legacy config defaults off", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600)) + + config, err := UpdateConfig(ConfigInput{ConfigPath: configPath}) + require.NoError(t, err) + require.NotNil(t, config.RemoteJobsAllowed, "RemoteJobsAllowed should be materialized") + assert.False(t, *config.RemoteJobsAllowed, "remote jobs must default off") + }) + + for _, tt := range []struct { + name string + input *bool + want bool + }{ + {"enable", util.True(), true}, + {"disable", util.False(), false}, + } { + t.Run(tt.name, func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600)) + + config, err := UpdateConfig(ConfigInput{ConfigPath: configPath, RemoteJobsAllowed: tt.input}) + require.NoError(t, err) + require.NotNil(t, config.RemoteJobsAllowed) + assert.Equal(t, tt.want, *config.RemoteJobsAllowed) + }) + } +} + +func TestApplyMDMPolicyRemoteJobs(t *testing.T) { + t.Run("enables remote jobs and sets the upload URL override", func(t *testing.T) { + cfg := &Config{} + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{ + mdm.KeyRemoteJobsAllowed: true, + mdm.KeyBundleUploadURL: "https://upload.example.com", + })) + require.NotNil(t, cfg.RemoteJobsAllowed) + assert.True(t, *cfg.RemoteJobsAllowed, "MDM allowRemoteJobs must enable the flag") + assert.Equal(t, "https://upload.example.com", cfg.DebugBundleUploadURL, "MDM upload URL override must be applied") + }) + + t.Run("a non-https upload URL is rejected", func(t *testing.T) { + cfg := &Config{} + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{ + mdm.KeyBundleUploadURL: "http://insecure.example.com", + })) + assert.Empty(t, cfg.DebugBundleUploadURL, "a non-https upload URL must be skipped") + }) + + t.Run("dropping the key clears a previously-applied override", func(t *testing.T) { + cfg := &Config{DebugBundleUploadURL: "https://old.example.com"} + // A replacement policy that no longer carries the key must not leave + // the old upload target directing bundles. + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyRemoteJobsAllowed: true})) + assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared") + }) + + t.Run("an empty replacement policy clears a previously-applied override", func(t *testing.T) { + cfg := &Config{DebugBundleUploadURL: "https://old.example.com"} + // A policy that becomes empty entirely hits the IsEmpty early return; + // the override must still be cleared rather than surviving on the + // reused Config instance. + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{})) + assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared when the policy empties") + }) + + t.Run("an invalid upload URL clears a previously-applied override (fail closed)", func(t *testing.T) { + cfg := &Config{DebugBundleUploadURL: "https://old.example.com"} + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyBundleUploadURL: "not-a-url"})) + assert.Empty(t, cfg.DebugBundleUploadURL, "an invalid override must fail closed, not keep the stale target") + }) +} + func TestUpdateOldManagementURL(t *testing.T) { origProber := newMgmProber newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) { diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index eb9db07c4..64a8093c3 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -32,6 +32,8 @@ var allKeys = []string{ KeySplitTunnelMode, KeySplitTunnelApps, KeyLazyConnection, + KeyRemoteJobsAllowed, + KeyBundleUploadURL, } // canonicalKey maps the lowercase form of a managed-config value name to diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 6c64acfc8..dac135ea6 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -62,6 +62,17 @@ const ( // the management feature flag. Read as a bool (native bool, or on/off, // true/false, 1/0, yes/no); absent = defer to management. KeyLazyConnection = "lazyConnection" + + // KeyRemoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Read as a bool; absent = defer to the local config + // (which defaults to disabled). Stored on Config as RemoteJobsAllowed. + KeyRemoteJobsAllowed = "allowRemoteJobs" + + // KeyBundleUploadURL overrides the debug-bundle upload service URL for + // remote jobs, taking precedence over the management-supplied value. Read + // as a string; must be an https URL with a host. Absent = defer to the + // management-supplied URL (or the default upload server). + KeyBundleUploadURL = "debugBundleUploadURL" ) // Split-tunnel mode literals (KeySplitTunnelMode values). @@ -73,6 +84,8 @@ const ( // SecretKeys lists keys whose values must be redacted in logs. var SecretKeys = map[string]struct{}{ KeyPreSharedKey: {}, + // The upload URL can embed credentials or signed query tokens. + KeyBundleUploadURL: {}, } // boolStringLiterals enumerates the textual boolean encodings the diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 089f3b95b..7f3ce1bbf 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -345,8 +345,11 @@ type LoginRequest struct { 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 + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + RemoteJobsAllowed *bool `protobuf:"varint,43,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LoginRequest) Reset() { @@ -674,6 +677,13 @@ func (x *LoginRequest) GetLocalMetricsAddress() string { return "" } +func (x *LoginRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -1231,6 +1241,7 @@ type GetConfigResponse struct { DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` + RemoteJobsAllowed bool `protobuf:"varint,29,opt,name=remoteJobsAllowed,proto3" json:"remoteJobsAllowed,omitempty"` // mDMManagedFields lists the names of configuration keys whose value is // currently enforced by an MDM policy. Names match mdm.Key* constants // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should @@ -1460,6 +1471,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool { return false } +func (x *GetConfigResponse) GetRemoteJobsAllowed() bool { + if x != nil { + return x.RemoteJobsAllowed + } + return false +} + func (x *GetConfigResponse) GetMDMManagedFields() []string { if x != nil { return x.MDMManagedFields @@ -4251,8 +4269,11 @@ type SetConfigRequest struct { 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 + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + RemoteJobsAllowed *bool `protobuf:"varint,38,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SetConfigRequest) Reset() { @@ -4544,6 +4565,13 @@ func (x *SetConfigRequest) GetLocalMetricsAddress() string { return "" } +func (x *SetConfigRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -7064,7 +7092,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\"\x92\x14\n" + + "\fEmptyRequest\"\xdb\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" + @@ -7111,7 +7139,8 @@ const file_daemon_proto_rawDesc = "" + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\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" + + "\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18+ \x01(\bH\x1eR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7141,7 +7170,8 @@ const file_daemon_proto_rawDesc = "" + "\x0f_sshJWTCacheTTLB\x0f\n" + "\r_disable_ipv6B\x17\n" + "\x15_enable_local_metricsB\x18\n" + - "\x16_local_metrics_address\"\xb5\x01\n" + + "\x16_local_metrics_addressB\x14\n" + + "\x12_remoteJobsAllowed\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -7176,7 +7206,7 @@ const file_daemon_proto_rawDesc = "" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xd8\t\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + "\n" + @@ -7208,7 +7238,8 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + - "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" + + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12,\n" + + "\x11remoteJobsAllowed\x18\x1d \x01(\bR\x11remoteJobsAllowed\x12*\n" + "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + @@ -7436,7 +7467,7 @@ const file_daemon_proto_rawDesc = "" + "\f_profileNameB\v\n" + "\t_username\"'\n" + "\x15SwitchProfileResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\xbb\x12\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x84\x13\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -7478,7 +7509,8 @@ const file_daemon_proto_rawDesc = "" + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\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" + + "\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18& \x01(\bH\x1bR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7505,7 +7537,8 @@ const file_daemon_proto_rawDesc = "" + "\x0f_sshJWTCacheTTLB\x0f\n" + "\r_disable_ipv6B\x17\n" + "\x15_enable_local_metricsB\x18\n" + - "\x16_local_metrics_address\"\x13\n" + + "\x16_local_metrics_addressB\x14\n" + + "\x12_remoteJobsAllowed\"\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 ad59a78f8..3953f9c15 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -245,6 +245,9 @@ message LoginRequest { optional bool enable_local_metrics = 41; optional string local_metrics_address = 42; + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + optional bool remoteJobsAllowed = 43; } message LoginResponse { @@ -365,6 +368,8 @@ message GetConfigResponse { bool disable_ipv6 = 27; + bool remoteJobsAllowed = 29; + // mDMManagedFields lists the names of configuration keys whose value is // currently enforced by an MDM policy. Names match mdm.Key* constants // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should @@ -772,6 +777,9 @@ message SetConfigRequest { optional bool enable_local_metrics = 36; optional string local_metrics_address = 37; + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + optional bool remoteJobsAllowed = 38; } message SetConfigResponse{} diff --git a/client/server/mdm.go b/client/server/mdm.go index 552fba94f..b41e2b590 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -315,6 +315,7 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), @@ -352,6 +353,7 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.Mtu != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.RemoteJobsAllowed != nil || msg.NetworkMonitor != nil || msg.DisableClientRoutes != nil || msg.DisableServerRoutes != nil || @@ -392,6 +394,7 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.WireguardPort != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.RemoteJobsAllowed != nil || msg.RosenpassPermissive != nil || len(msg.ExtraIFaceBlacklist) > 0 || msg.NetworkMonitor != nil || @@ -442,6 +445,7 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), diff --git a/client/server/server.go b/client/server/server.go index b066e9719..a69c94774 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -38,6 +38,7 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/capture" "github.com/netbirdio/netbird/version" ) @@ -586,6 +587,7 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile config.LocalMetricsAddress = msg.LocalMetricsAddress config.DisableAutoConnect = msg.DisableAutoConnect config.ServerSSHAllowed = msg.ServerSSHAllowed + config.RemoteJobsAllowed = msg.RemoteJobsAllowed config.NetworkMonitor = msg.NetworkMonitor config.DisableClientRoutes = msg.DisableClientRoutes config.DisableServerRoutes = msg.DisableServerRoutes @@ -2189,6 +2191,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p Mtu: int64(cfg.MTU), DisableAutoConnect: cfg.DisableAutoConnect, ServerSSHAllowed: *cfg.ServerSSHAllowed, + RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(cfg.RemoteJobsAllowed), RosenpassEnabled: cfg.RosenpassEnabled, RosenpassPermissive: cfg.RosenpassPermissive, BlockInbound: cfg.BlockInbound, diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index d8309f519..7442b718e 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -61,6 +61,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { rosenpassEnabled := true rosenpassPermissive := true serverSSHAllowed := true + remoteJobsAllowed := true interfaceName := "utun100" wireguardPort := int64(51820) preSharedKey := "test-psk" @@ -87,6 +88,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { RosenpassEnabled: &rosenpassEnabled, RosenpassPermissive: &rosenpassPermissive, ServerSSHAllowed: &serverSSHAllowed, + RemoteJobsAllowed: &remoteJobsAllowed, InterfaceName: &interfaceName, WireguardPort: &wireguardPort, OptionalPreSharedKey: &preSharedKey, @@ -132,6 +134,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive) require.NotNil(t, cfg.ServerSSHAllowed) require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed) + require.NotNil(t, cfg.RemoteJobsAllowed) + require.Equal(t, remoteJobsAllowed, *cfg.RemoteJobsAllowed) require.Equal(t, interfaceName, cfg.WgIface) require.Equal(t, int(wireguardPort), cfg.WgPort) require.Equal(t, preSharedKey, cfg.PreSharedKey) @@ -186,6 +190,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "RosenpassEnabled": true, "RosenpassPermissive": true, "ServerSSHAllowed": true, + "RemoteJobsAllowed": true, "InterfaceName": true, "WireguardPort": true, "OptionalPreSharedKey": true, @@ -248,6 +253,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-rosenpass": "RosenpassEnabled", "rosenpass-permissive": "RosenpassPermissive", "allow-server-ssh": "ServerSSHAllowed", + "allow-remote-jobs": "RemoteJobsAllowed", "interface-name": "InterfaceName", "wireguard-port": "WireguardPort", "preshared-key": "OptionalPreSharedKey", diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go index 3b62f5e56..01d24687e 100644 --- a/client/server/ssh_gate.go +++ b/client/server/ssh_gate.go @@ -44,6 +44,7 @@ import ( type privilegedConfigChange struct { managementURL string serverSSHAllowed *bool + remoteJobsAllowed *bool enableSSHRoot *bool disableSSHAuth *bool enableLocalMetrics *bool @@ -54,6 +55,7 @@ func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfig return privilegedConfigChange{ managementURL: msg.GetManagementUrl(), serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, enableSSHRoot: msg.EnableSSHRoot, disableSSHAuth: msg.DisableSSHAuth, enableLocalMetrics: msg.EnableLocalMetrics, @@ -65,6 +67,7 @@ func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange { return privilegedConfigChange{ managementURL: msg.GetManagementUrl(), serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, enableSSHRoot: msg.EnableSSHRoot, disableSSHAuth: msg.DisableSSHAuth, enableLocalMetrics: msg.EnableLocalMetrics, @@ -92,6 +95,15 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh")) } + // Enabling remote jobs lets the management server run jobs (e.g. debug + // bundles) on this host, so turning it on crosses the user-to-root + // boundary the same way enabling the SSH server does. The stored value + // defaults to off (nil = off), so a legacy config is correctly seen as + // off and turning it on requires privilege. + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.RemoteJobsAllowed }), change.remoteJobsAllowed) { + return denyPrivileged(ctx, "enabling remote jobs", ipcauth.UpCommand("--allow-remote-jobs")) + } + if addr, exposes := exposesLocalMetrics(stored, change); exposes { return denyPrivileged(ctx, "exposing the local metrics endpoint on a non-loopback address", diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go index d71cd86ef..b4712c64a 100644 --- a/client/server/ssh_gate_test.go +++ b/client/server/ssh_gate_test.go @@ -173,6 +173,34 @@ func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) { stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)}, change: privilegedConfigChange{disableSSHAuth: boolPtr(false)}, }, + { + name: "enabling remote jobs unprivileged is refused", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling remote jobs as root is allowed", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "a profile with no config yet counts as off, so enabling remote jobs is refused", + stored: nil, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "restating already-enabled remote jobs is not a change", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + }, + { + name: "turning remote jobs off is not guarded", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(false)}, + }, { name: "a request that touches none of the guarded fields is allowed", stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, diff --git a/client/system/info.go b/client/system/info.go index daeabca13..273c7a533 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -65,6 +65,7 @@ type Info struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed bool + RemoteJobsAllowed bool DisableClientRoutes bool DisableServerRoutes bool @@ -90,12 +91,16 @@ func (i *Info) SetFlags( disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int, enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool, disableSSHAuth *bool, + remoteJobsAllowed *bool, ) { i.RosenpassEnabled = rosenpassEnabled i.RosenpassPermissive = rosenpassPermissive if serverSSHAllowed != nil { i.ServerSSHAllowed = *serverSSHAllowed } + if remoteJobsAllowed != nil { + i.RemoteJobsAllowed = *remoteJobsAllowed + } i.DisableClientRoutes = disableClientRoutes i.DisableServerRoutes = disableServerRoutes diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist index fe10b5b63..eec96d35b 100644 --- a/docs/io.netbird.client.plist +++ b/docs/io.netbird.client.plist @@ -85,6 +85,21 @@ --> + + + + + +