From fbd4730f0bb302a52c9458eba1acd025930f1b17 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 3 Sep 2026 11:47:32 +0200 Subject: [PATCH 01/63] [client] Expose the remote jobs opt-in in the Android and iOS SDK preferences (#7406) Remote jobs (debug bundle requests from management) are gated behind Config.RemoteJobsAllowed, which defaults to false and could only be enabled through the CLI flag or an MDM policy. The mobile SDKs had no way to set it, so the mobile clients always refused the job. Add GetRemoteJobsAllowed/SetRemoteJobsAllowed to both mobile Preferences types, following the existing ServerSSHAllowed accessors, so the apps can offer a settings toggle for it. --- client/android/preferences.go | 21 +++++++++++++++++++++ client/ios/NetBirdSDK/preferences.go | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/client/android/preferences.go b/client/android/preferences.go index 066477293..d90365518 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -325,6 +325,27 @@ func (p *Preferences) SetDisableIPv6(disable bool) { p.configInput.DisableIPv6 = &disable } +// GetRemoteJobsAllowed reads the remote jobs opt-in from config file +func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { + if p.configInput.RemoteJobsAllowed != nil { + return *p.configInput.RemoteJobsAllowed, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + if cfg.RemoteJobsAllowed == nil { + return false, nil + } + return *cfg.RemoteJobsAllowed, err +} + +// SetRemoteJobsAllowed stores the given value and waits for commit +func (p *Preferences) SetRemoteJobsAllowed(allowed bool) { + p.configInput.RemoteJobsAllowed = &allowed +} + // Commit writes out the changes to the config file func (p *Preferences) Commit() error { _, err := profilemanager.UpdateOrCreateConfig(p.configInput) diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index ed49ccddb..39aa7ed83 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -128,6 +128,27 @@ func (p *Preferences) SetDisableIPv6(disable bool) { p.configInput.DisableIPv6 = &disable } +// GetRemoteJobsAllowed reads the remote jobs opt-in from config file +func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { + if p.configInput.RemoteJobsAllowed != nil { + return *p.configInput.RemoteJobsAllowed, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + if cfg.RemoteJobsAllowed == nil { + return false, nil + } + return *cfg.RemoteJobsAllowed, err +} + +// SetRemoteJobsAllowed stores the given value and waits for commit +func (p *Preferences) SetRemoteJobsAllowed(allowed bool) { + p.configInput.RemoteJobsAllowed = &allowed +} + // Commit write out the changes into config file func (p *Preferences) Commit() error { // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) From bb233c72b6a00c8e84bc72bca62264a31f79896c Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:22:00 +0900 Subject: [PATCH 02/63] [client] Rebuild the overlay listeners when the TUN is renewed (#7397) --- client/internal/dnsfwd/forwarder.go | 125 ++++++++++++++++++++--- client/internal/dnsfwd/forwarder_test.go | 52 ++++++++++ client/internal/dnsfwd/manager.go | 10 ++ client/internal/engine.go | 87 +++++++++++++++- client/internal/engine_ssh.go | 36 ++++++- client/ssh/auth/auth.go | 19 ++++ client/ssh/server/server.go | 33 +++++- 7 files changed, 341 insertions(+), 21 deletions(-) diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index b7e5a10e3..e3cb597be 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -54,12 +54,20 @@ type DNSForwarder struct { ttl uint32 statusRecorder *peer.Status - dnsServer *dns.Server - mux *dns.ServeMux - tcpServer *dns.Server - tcpMux *dns.ServeMux + mux *dns.ServeMux + tcpMux *dns.ServeMux - mutex sync.RWMutex + mutex sync.RWMutex + // closed records that Close has run, so a Listen still in flight does not + // go on to serve sockets nobody will shut down. + closed bool + // The sockets are kept alongside the servers because closing them is the + // only stop that always works: a server whose ActivateAndServe has not run + // yet refuses to shut down, and would otherwise start serving afterwards. + udpConn net.PacketConn + tcpLn net.Listener + dnsServer *dns.Server + tcpServer *dns.Server fwdEntries []*ForwarderEntry firewall firewaller resolver resolver @@ -106,7 +114,7 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error { mux := dns.NewServeMux() f.mux = mux mux.HandleFunc(".", f.handleDNSQueryUDP) - f.dnsServer = &dns.Server{ + dnsServer := &dns.Server{ PacketConn: udpLn, Handler: mux, } @@ -114,22 +122,32 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error { tcpMux := dns.NewServeMux() f.tcpMux = tcpMux tcpMux.HandleFunc(".", f.handleDNSQueryTCP) - f.tcpServer = &dns.Server{ + tcpServer := &dns.Server{ Listener: tcpLn, Handler: tcpMux, } - f.UpdateDomains(entries) + if !f.publish(udpLn, tcpLn, dnsServer, tcpServer, entries) { + log.Infof("DNS forwarder on %s was closed before it started serving", addrDesc) + if err := udpLn.Close(); err != nil { + log.Debugf("close UDP listener of a closed forwarder: %v", err) + } + if err := tcpLn.Close(); err != nil { + log.Debugf("close TCP listener of a closed forwarder: %v", err) + } + return nil + } + log.Debugf("DNS forwarder serving %d domains", len(entries)) errCh := make(chan error, 2) go func() { log.Infof("DNS UDP listener running on %s", addrDesc) - errCh <- f.dnsServer.ActivateAndServe() + errCh <- dnsServer.ActivateAndServe() }() go func() { log.Infof("DNS TCP listener running on %s", addrDesc) - errCh <- f.tcpServer.ActivateAndServe() + errCh <- tcpServer.ActivateAndServe() }() return <-errCh @@ -151,6 +169,46 @@ func (f *DNSForwarder) createTCPListener(netstackNet *netstack.Net) (net.Listene return net.ListenTCP("tcp", net.TCPAddrFromAddrPort(f.listenAddress)) } +// publish hands the sockets, servers and entries to the forwarder so Close can +// reach them and Domains can report them, and says whether serving may begin. +// Listen runs on its own goroutine, so a Close can arrive before it gets this +// far; false means the caller must close what it created instead of serving on +// it. +// +// The entries go in under the same lock rather than afterwards. Anything that +// reads them in between would otherwise see a forwarder that is listening and +// serves no domain, which for a caller rebuilding one means it comes back +// refusing every routed query. +func (f *DNSForwarder) publish( + udpConn net.PacketConn, + tcpLn net.Listener, + dnsServer, tcpServer *dns.Server, + entries []*ForwarderEntry, +) bool { + f.mutex.Lock() + defer f.mutex.Unlock() + + if f.closed { + return false + } + + f.udpConn = udpConn + f.tcpLn = tcpLn + f.dnsServer = dnsServer + f.tcpServer = tcpServer + f.fwdEntries = entries + return true +} + +// Domains returns the entries currently being served. The slice is replaced +// wholesale by UpdateDomains rather than mutated, so the caller may read it but +// must not write to it. +func (f *DNSForwarder) Domains() []*ForwarderEntry { + f.mutex.RLock() + defer f.mutex.RUnlock() + return f.fwdEntries +} + func (f *DNSForwarder) UpdateDomains(entries []*ForwarderEntry) { f.mutex.Lock() defer f.mutex.Unlock() @@ -189,19 +247,45 @@ func (f *DNSForwarder) removeStaleCacheEntries(oldEntries, newEntries []*Forward } func (f *DNSForwarder) Close(ctx context.Context) error { + // Marked closed under the lock so a Listen that has not published its + // servers yet gives up instead of racing this shutdown. The shutdowns + // themselves block, so they run outside it. + f.mutex.Lock() + f.closed = true + dnsServer, tcpServer := f.dnsServer, f.tcpServer + udpConn, tcpLn := f.udpConn, f.tcpLn + f.mutex.Unlock() + var result *multierror.Error - if f.dnsServer != nil { - if err := f.dnsServer.ShutdownContext(ctx); err != nil { + if dnsServer != nil { + if err := shutdownServer(ctx, dnsServer); err != nil { result = multierror.Append(result, fmt.Errorf("UDP shutdown: %w", err)) } } - if f.tcpServer != nil { - if err := f.tcpServer.ShutdownContext(ctx); err != nil { + if tcpServer != nil { + if err := shutdownServer(ctx, tcpServer); err != nil { result = multierror.Append(result, fmt.Errorf("TCP shutdown: %w", err)) } } + // The sockets are closed even when the shutdowns above reported nothing to + // do. A server that has been published but has not reached + // ActivateAndServe refuses to shut down, and closing what it was about to + // serve on is what stops it: the alternative is a listener still answering + // on an interface that has gone away. A shutdown that did run has already + // closed these, so the second close is expected to fail. + if udpConn != nil { + if err := udpConn.Close(); err != nil { + log.Debugf("close UDP socket of the DNS forwarder: %v", err) + } + } + if tcpLn != nil { + if err := tcpLn.Close(); err != nil { + log.Debugf("close TCP socket of the DNS forwarder: %v", err) + } + } + return nberrors.FormatErrorOrNil(result) } @@ -514,3 +598,16 @@ func attachEDE(resp *dns.Msg, code uint16, text string) { } opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text}) } + +// shutdownServer shuts a server down gracefully, treating "never started" as +// success. A server that was published but has not reached ActivateAndServe +// has nothing to wind down, and the caller closes its socket regardless, which +// is what actually stops it. dns exports no sentinel for this, so the message +// is all there is to match on. +func shutdownServer(ctx context.Context, server *dns.Server) error { + err := server.ShutdownContext(ctx) + if err == nil || strings.Contains(err.Error(), "server not started") { + return nil + } + return err +} diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go index c69a9166e..a64ba80e7 100644 --- a/client/internal/dnsfwd/forwarder_test.go +++ b/client/internal/dnsfwd/forwarder_test.go @@ -1238,3 +1238,55 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) { assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query") } + +// TestDNSForwarder_ClosedBeforeItServes covers Listen reaching the point of +// serving after the forwarder has already been closed. Listen runs on its own +// goroutine, so it can get there late, and a socket it starts serving then is +// one nothing will ever close: on Android it keeps answering on an interface +// that has been replaced. The close is sequenced first here rather than raced, +// which pins the same state deterministically. +func TestDNSForwarder_ClosedBeforeItServes(t *testing.T) { + f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil) + + require.NoError(t, f.Close(context.Background()), "closing a forwarder that never started") + + done := make(chan error, 1) + go func() { done <- f.Listen(nil) }() + + select { + case err := <-done: + assert.NoError(t, err, "a closed forwarder should give up quietly, not serve") + case <-time.After(5 * time.Second): + t.Fatal("Listen went on to serve after the forwarder was closed") + } +} + +// TestDNSForwarder_CloseStopsUnactivatedServers covers the window between +// Listen publishing its servers and reaching ActivateAndServe. A server that +// has not been activated refuses to shut down, so Close has to close the +// sockets itself or they are left serving. +func TestDNSForwarder_CloseStopsUnactivatedServers(t *testing.T) { + f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil) + + udpConn, err := f.createUDPListener(nil) + require.NoError(t, err, "create UDP listener") + tcpLn, err := f.createTCPListener(nil) + require.NoError(t, err, "create TCP listener") + + // Published but deliberately never activated, which is the state Listen is + // in for the moment before it starts serving. + require.True(t, f.publish(udpConn, tcpLn, &dns.Server{PacketConn: udpConn}, &dns.Server{Listener: tcpLn}, nil), + "publishing to an open forwarder") + + tcpAddr := tcpLn.Addr().String() + require.NoError(t, f.Close(context.Background()), "close should report no error for servers it could not shut down") + + _, err = tcpLn.Accept() + assert.Error(t, err, "the TCP socket should be closed after Close") + + conn, err := net.DialTimeout("tcp", tcpAddr, time.Second) + if err == nil { + _ = conn.Close() + t.Fatal("the forwarder is still accepting connections after Close") + } +} diff --git a/client/internal/dnsfwd/manager.go b/client/internal/dnsfwd/manager.go index b8684d177..1c62e908d 100644 --- a/client/internal/dnsfwd/manager.go +++ b/client/internal/dnsfwd/manager.go @@ -117,6 +117,16 @@ func (m *Manager) UpdateDomains(entries []*ForwarderEntry) { m.dnsForwarder.UpdateDomains(entries) } +// Domains returns the entries currently being served, or nil when the +// forwarder is not running. +func (m *Manager) Domains() []*ForwarderEntry { + if m.dnsForwarder == nil { + return nil + } + + return m.dnsForwarder.Domains() +} + func (m *Manager) Stop(ctx context.Context) error { if m.dnsForwarder == nil { return nil diff --git a/client/internal/engine.go b/client/internal/engine.go index 72361c8cb..477fba194 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -94,6 +94,13 @@ const ( // exec, os.Stat); without this bound a single stuck call freezes handleSync, and // thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes). systemInfoTimeout = 15 * time.Second + + // dnsForwarderStopTimeout bounds how long stopping the DNS forwarder waits + // for the queries still in flight. One waiting on an unresponsive upstream + // would otherwise hold the stop for the whole upstream timeout, and the + // stop runs with syncMsgMux held. The sockets are closed either way, so + // giving up costs a query that was already failing. + dnsForwarderStopTimeout = 2 * time.Second ) var ErrResetConnection = fmt.Errorf("reset connection") @@ -321,6 +328,10 @@ type localIpUpdater interface { UpdateLocalIPs() error } +// overlayRebind rebuilds one subsystem's sockets on the current interface. The +// error it returns names its own subsystem, since the caller can only log it. +type overlayRebind func() error + // NewEngine creates a new Connection Engine with probes attached func NewEngine( clientCtx context.Context, @@ -2502,7 +2513,72 @@ func (e *Engine) RenewTun(fd int) error { return fmt.Errorf("wireguard interface not initialized") } - return wgInterface.RenewTun(fd) + if err := wgInterface.RenewTun(fd); err != nil { + return err + } + + e.rebindOverlayListeners() + return nil +} + +// rebindOverlayListeners gives the servers that listen on an overlay address +// sockets on the interface as it is now. +// +// A socket belongs to the interface generation it was created on. Renewing the +// TUN builds a new interface and moves the overlay addresses to it, which +// leaves the old sockets in LISTEN with the uspfilter still logging packets +// arriving for them, while every accept fails with EINVAL for the life of the +// socket: from the outside the server looks alive and answers nothing. On +// Android this happens during a normal startup, where the first TUN is +// established before the routes are known and replaced once they arrive. +// +// Rebinding costs whatever those sockets were carrying, which the renewal has +// already broken. Errors are logged rather than returned: the renewal itself +// succeeded, and failing it would hand the caller a working interface and an +// error. +func (e *Engine) rebindOverlayListeners() { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + + for _, rebind := range e.overlayRebinds() { + if err := rebind(); err != nil { + log.Errorf("after TUN renewal: %v", err) + } + } +} + +// overlayRebinds is every subsystem of this engine that holds sockets bound to +// an overlay address, and how to rebuild each one's. +// +// A subsystem that starts listening on an overlay address belongs in this list. +// Leaving it out costs nothing that review would notice and produces a listener +// that stays in LISTEN, is logged as receiving packets, and refuses every +// connection for the life of the process. +func (e *Engine) overlayRebinds() []overlayRebind { + return []overlayRebind{ + e.restartSSHListeners, + e.restartDNSForwarder, + } +} + +// restartDNSForwarder rebuilds the DNS forwarder serving the same domains. +// No-op when it is not running. See Engine.rebindOverlayListeners. +func (e *Engine) restartDNSForwarder() error { + if e.dnsForwardMgr == nil { + return nil + } + // Read from the forwarder before it goes away, so the replacement serves + // the domains in force now rather than a copy kept somewhere else. + entries := e.dnsForwardMgr.Domains() + e.stopDNSForwarder() + // Both halves log their own failures, so the only thing left to report is + // the outcome: a start that failed left the manager nil, and the forwarder + // is now down rather than merely rebound. + e.startDNSForwarder(entries) + if e.dnsForwardMgr == nil { + return errors.New("rebind DNS forwarder: it did not come back up") + } + return nil } // updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag @@ -2548,7 +2624,14 @@ func (e *Engine) stopDNSForwarder() { return } - if err := e.dnsForwardMgr.Stop(context.Background()); err != nil { + // Bounded because the shutdown waits for queries still in flight, and one + // waiting on an unresponsive upstream holds it for as long as that lookup + // is allowed to take. This runs with syncMsgMux held, so that wait is one + // the whole engine spends. + ctx, cancel := context.WithTimeout(context.Background(), dnsForwarderStopTimeout) + defer cancel() + + if err := e.dnsForwardMgr.Stop(ctx); err != nil { log.Errorf("failed to stop DNS forward: %v", err) } diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go index 53d2c1122..60bdfd806 100644 --- a/client/internal/engine_ssh.go +++ b/client/internal/engine_ssh.go @@ -24,6 +24,8 @@ type sshServer interface { Stop() error GetStatus() (bool, []sshserver.SessionInfo) UpdateSSHAuth(config *sshauth.Config) + JWTConfig() *sshserver.JWTConfig + AuthConfig() *sshauth.Config } func (e *Engine) setupSSHPortRedirection() error { @@ -77,7 +79,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { if e.config.DisableSSHAuth != nil && *e.config.DisableSSHAuth { log.Info("starting SSH server without JWT authentication (authentication disabled by config)") - return e.startSSHServer(nil) + return e.startSSHServer(nil, nil) } if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil { @@ -95,7 +97,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { MaxTokenAge: protoJWT.GetMaxTokenAge(), } - return e.startSSHServer(jwtConfig) + return e.startSSHServer(jwtConfig, nil) } return errors.New("SSH server requires valid JWT configuration") @@ -231,8 +233,33 @@ func (e *Engine) cleanupSSHConfig() { } } -// startSSHServer initializes and starts the SSH server with proper configuration. -func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { +// restartSSHListeners rebuilds the SSH server so it listens on new sockets, on +// the same terms it was started with. No-op when it is not running. See +// Engine.rebindOverlayListeners for why this is needed. +func (e *Engine) restartSSHListeners() error { + if e.sshServer == nil { + return nil + } + // Read from the server before it goes away. A rebuilt one starts with an + // empty authorizer, which fails closed, so without carrying the + // authorization over every JWT login is refused until the next network map + // happens to bring one. + jwtConfig, authConfig := e.sshServer.JWTConfig(), e.sshServer.AuthConfig() + if err := e.stopSSHServer(); err != nil { + return fmt.Errorf("rebind SSH listeners: %w", err) + } + if err := e.startSSHServer(jwtConfig, authConfig); err != nil { + return fmt.Errorf("rebind SSH listeners: %w", err) + } + return nil +} + +// startSSHServer initializes and starts the SSH server with proper +// configuration. authConfig is the fine-grained authorization to open with, and +// is applied before the server accepts anything: a server that starts listening +// with an empty authorizer refuses the logins that arrive in the meantime. +// Nil leaves it as management has not sent one yet. +func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig, authConfig *sshauth.Config) error { if e.wgInterface == nil { return errors.New("wg interface not initialized") } @@ -240,6 +267,7 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { serverConfig := &sshserver.Config{ HostKeyPEM: e.config.SSHKey, JWT: jwtConfig, + Auth: authConfig, } server := sshserver.New(serverConfig) diff --git a/client/ssh/auth/auth.go b/client/ssh/auth/auth.go index 079282fdc..92f517fac 100644 --- a/client/ssh/auth/auth.go +++ b/client/ssh/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "errors" "fmt" + "slices" "sync" log "github.com/sirupsen/logrus" @@ -155,6 +156,24 @@ func (a *Authorizer) GetUserIDClaim() string { return a.userIDClaim } +// Config returns the authorization currently in force. The user list and the +// machine-user map are copies; the originals stay in use here. +func (a *Authorizer) Config() *Config { + a.mu.RLock() + defer a.mu.RUnlock() + + machineUsers := make(map[string][]uint32, len(a.machineUsers)) + for osUser, indexes := range a.machineUsers { + machineUsers[osUser] = slices.Clone(indexes) + } + + return &Config{ + UserIDClaim: a.userIDClaim, + AuthorizedUsers: slices.Clone(a.authorizedUsers), + MachineUsers: machineUsers, + } +} + // findUserIndex finds the index of a hashed user ID in the authorized users list // Returns the index and true if found, 0 and false if not found func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) { diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index 6735e0f3b..b32da796e 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -197,6 +197,12 @@ type Config struct { // HostKey is the SSH server host key in PEM format HostKeyPEM []byte + + // Auth is the fine-grained authorization to open with. Nil starts with an + // empty authorizer, which authorizes nobody until UpdateSSHAuth is called. + // Setting it here rather than afterwards means the server never accepts a + // login before it knows who is allowed. + Auth *sshauth.Config } // SessionInfo contains information about an active SSH session @@ -220,7 +226,11 @@ func New(config *Config) *Server { connections: make(map[connKey]*connState), jwtEnabled: config.JWT != nil, jwtConfig: config.JWT, - authorizer: sshauth.NewAuthorizer(), // Initialize with empty config + authorizer: sshauth.NewAuthorizer(), + } + + if config.Auth != nil { + s.authorizer.Update(config.Auth) } return s @@ -461,6 +471,27 @@ func (s *Server) UpdateSSHAuth(config *sshauth.Config) { s.authorizer.Update(config) } +// JWTConfig returns the JWT authentication this server was built with, or nil +// when JWT authentication is disabled. +func (s *Server) JWTConfig() *JWTConfig { + s.mu.RLock() + defer s.mu.RUnlock() + return s.jwtConfig +} + +// AuthConfig returns the fine-grained authorization currently in force, or nil +// when the server has no authorizer. +func (s *Server) AuthConfig() *sshauth.Config { + s.mu.RLock() + authorizer := s.authorizer + s.mu.RUnlock() + + if authorizer == nil { + return nil + } + return authorizer.Config() +} + // ensureJWTValidator initializes the JWT validator and extractor if not already initialized func (s *Server) ensureJWTValidator() error { s.mu.RLock() From 7c1253004b1c1f95d343c0db8a9971680e0687f4 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:22:19 +0900 Subject: [PATCH 03/63] [client] Renew the Android TUN only when the routes it carries change (#7396) --- .../routemanager/notifier/notifier_android.go | 18 ---- .../routemanager/notifier/route_diff.go | 27 ++++++ .../routemanager/notifier/route_diff_test.go | 88 +++++++++++++++++++ 3 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 client/internal/routemanager/notifier/route_diff.go create mode 100644 client/internal/routemanager/notifier/route_diff_test.go diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index 5fa329310..24cbb94db 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -4,8 +4,6 @@ package notifier import ( "net/netip" - "slices" - "sort" "sync" "github.com/netbirdio/netbird/client/internal/listener" @@ -75,19 +73,3 @@ func (n *Notifier) notifyLocked() { func (n *Notifier) Close() { // unused } - -func routesToStrings(routes []*route.Route) []string { - nets := make([]string, 0, len(routes)) - for _, r := range routes { - nets = append(nets, r.NetString()) - } - return nets -} - -func hasRouteDiff(a []*route.Route, b []*route.Route) bool { - as := routesToStrings(a) - bs := routesToStrings(b) - sort.Strings(as) - sort.Strings(bs) - return !slices.Equal(as, bs) -} diff --git a/client/internal/routemanager/notifier/route_diff.go b/client/internal/routemanager/notifier/route_diff.go new file mode 100644 index 000000000..52abddf36 --- /dev/null +++ b/client/internal/routemanager/notifier/route_diff.go @@ -0,0 +1,27 @@ +package notifier + +import ( + "slices" + "sort" + + "github.com/netbirdio/netbird/route" +) + +// routePrefixes returns the distinct prefixes a route set covers, sorted. +// Duplicates are dropped deliberately: an HA group hands us one route per +// peer serving the same prefix, and the platform is given the prefix, not the +// candidates. Counting them would report a change every time a peer joins or +// leaves a group, and on Android each report renews the TUN. +func routePrefixes(routes []*route.Route) []string { + nets := make([]string, 0, len(routes)) + for _, r := range routes { + nets = append(nets, r.NetString()) + } + sort.Strings(nets) + return slices.Compact(nets) +} + +// hasRouteDiff reports whether the prefixes the two route sets cover differ. +func hasRouteDiff(a []*route.Route, b []*route.Route) bool { + return !slices.Equal(routePrefixes(a), routePrefixes(b)) +} diff --git a/client/internal/routemanager/notifier/route_diff_test.go b/client/internal/routemanager/notifier/route_diff_test.go new file mode 100644 index 000000000..80df69d9d --- /dev/null +++ b/client/internal/routemanager/notifier/route_diff_test.go @@ -0,0 +1,88 @@ +package notifier + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/route" +) + +func routeFor(id route.ID, prefix string) *route.Route { + return &route.Route{ + ID: id, + NetID: "net", + Network: netip.MustParsePrefix(prefix), + } +} + +// TestHasRouteDiff_IgnoresHACandidateCount is the reason the comparison +// deduplicates. Every notification renews the TUN, and a renewed TUN +// invalidates the sockets the embedded servers are listening on, so a peer +// joining or leaving an HA group must not count as a route change when the +// prefixes the TUN carries are identical. +func TestHasRouteDiff_IgnoresHACandidateCount(t *testing.T) { + onePeer := []*route.Route{routeFor("a", "10.0.0.0/24")} + twoPeers := []*route.Route{ + routeFor("a", "10.0.0.0/24"), + routeFor("b", "10.0.0.0/24"), + } + + assert.False(t, hasRouteDiff(onePeer, twoPeers), + "a second peer serving the same prefix is not a route change") + assert.False(t, hasRouteDiff(twoPeers, onePeer), + "losing one of two peers serving the same prefix is not a route change") +} + +func TestHasRouteDiff_ReportsRealChanges(t *testing.T) { + tests := []struct { + name string + a []*route.Route + b []*route.Route + want bool + }{ + { + name: "added prefix", + a: []*route.Route{routeFor("a", "10.0.0.0/24")}, + b: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")}, + want: true, + }, + { + name: "removed prefix", + a: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")}, + b: []*route.Route{routeFor("a", "10.0.0.0/24")}, + want: true, + }, + { + name: "replaced prefix", + a: []*route.Route{routeFor("a", "10.0.0.0/24")}, + b: []*route.Route{routeFor("a", "10.0.1.0/24")}, + want: true, + }, + { + name: "same prefix, different order", + a: []*route.Route{routeFor("a", "10.0.1.0/24"), routeFor("b", "10.0.0.0/24")}, + b: []*route.Route{routeFor("b", "10.0.0.0/24"), routeFor("a", "10.0.1.0/24")}, + want: false, + }, + { + name: "all routes gone", + a: []*route.Route{routeFor("a", "10.0.0.0/24")}, + b: nil, + want: true, + }, + { + name: "both empty", + a: nil, + b: nil, + want: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, hasRouteDiff(tc.a, tc.b), + "route diff for %s", tc.name) + }) + } +} From 798e4a0546fa4373c556564d14672fc433246fe7 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Thu, 3 Sep 2026 16:44:18 +0200 Subject: [PATCH 04/63] [misc] Skip the protobuf breaking check on branch-creation pushes (#7411) A push that creates a branch sends the all-zero SHA as `before`, and bufbuild/buf-action unconditionally builds its default baseline from it:(src/inputs.ts:86), so `buf breaking` failed cloning a ref that does not exist. This broke the first run on every new release-* branch, most recently release-0.78. Gate `breaking` on github.event.created instead. Nothing is lost: every commit on a freshly cut release branch already passed the check on main, and pushes with a real `before` -- plus pull requests, which compare against their own base -- keep the action's own default baseline, so stacked PRs are unaffected. Also drop `build: false`, which is not an input this action accepts and only produced a warning. --- .github/workflows/buf.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/buf.yml b/.github/workflows/buf.yml index a993293d4..cc7629534 100644 --- a/.github/workflows/buf.yml +++ b/.github/workflows/buf.yml @@ -27,7 +27,22 @@ jobs: push: false archive: false pr_comment: false - build: false lint: false format: false - breaking: true + # A push that creates a branch carries no `before` commit, so the + # action's default baseline is the all-zero SHA and `buf breaking` + # dies cloning it. Skipping costs nothing: every commit on a freshly + # cut release branch should have already passed this check on main. + breaking: ${{ !github.event.created }} + # The alternative is to compare against the default branch instead of + # skipping. Not used: buf clones the baseline when the job runs, so a + # main that has moved on since the branch was cut reads as protos + # deleted on the release branch. Resolving to an empty string on every + # other event is what keeps the action's own default in place, which + # stacked pull requests need. + # breaking_against: >- + # ${{ github.event.created + # && format('{0}#format=git,branch={1}', + # github.event.repository.clone_url, + # github.event.repository.default_branch) + # || '' }} From c2b5d211d94b855a8b69c7b3f7e392ddce25019b Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:15:20 +0900 Subject: [PATCH 05/63] [management] Enforce reverse proxy group access before minting and when honouring a session cookie (#7240) --- .../modules/reverseproxy/service/service.go | 1 + .../reverseproxy/service/service_test.go | 38 + management/internals/shared/grpc/proxy.go | 18 +- .../shared/grpc/validate_session_test.go | 51 ++ management/server/http/handlers/proxy/auth.go | 10 +- proxy/internal/auth/middleware.go | 65 +- proxy/internal/auth/middleware_test.go | 102 +-- proxy/internal/auth/session_groups_test.go | 167 ++++ proxy/internal/auth/tunnel_lookup_test.go | 10 +- proxy/management_integration_test.go | 1 + proxy/server.go | 2 +- shared/management/proto/proxy_service.pb.go | 798 +++++++++--------- shared/management/proto/proxy_service.proto | 6 + 13 files changed, 815 insertions(+), 454 deletions(-) create mode 100644 proxy/internal/auth/session_groups_test.go diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go index b6438abde..1488003cb 100644 --- a/management/internals/modules/reverseproxy/service/service.go +++ b/management/internals/modules/reverseproxy/service/service.go @@ -388,6 +388,7 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled { auth.Oidc = true + auth.AllowedGroupIds = append([]string(nil), s.Auth.BearerAuth.DistributionGroups...) } for _, h := range s.Auth.HeaderAuths { diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go index a149ac609..fc59968a2 100644 --- a/management/internals/modules/reverseproxy/service/service_test.go +++ b/management/internals/modules/reverseproxy/service/service_test.go @@ -250,6 +250,44 @@ func TestToProtoMapping_TargetOptions(t *testing.T) { assert.Equal(t, int64(30), opts.RequestTimeout.Seconds) } +// TestToProtoMapping_AllowedGroupIds covers the list the proxy gates session +// cookies on: without it the proxy can only check a cookie's signature, which +// makes a token minted for a user outside the groups a bearer credential. +func TestToProtoMapping_AllowedGroupIds(t *testing.T) { + t.Run("distribution groups reach the proxy", func(t *testing.T) { + rp := &Service{ + ID: "svc-1", + AccountID: "acc-1", + Domain: "example.com", + Auth: AuthConfig{ + BearerAuth: &BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"grp-1", "grp-2"}, + }, + }, + } + pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}) + + assert.True(t, pm.GetAuth().GetOidc()) + assert.Equal(t, []string{"grp-1", "grp-2"}, pm.GetAuth().GetAllowedGroupIds()) + }) + + t.Run("a service open to the account carries no groups", func(t *testing.T) { + rp := &Service{ + ID: "svc-1", + AccountID: "acc-1", + Domain: "example.com", + Auth: AuthConfig{ + BearerAuth: &BearerAuthConfig{Enabled: true}, + }, + } + pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}) + + assert.True(t, pm.GetAuth().GetOidc()) + assert.Empty(t, pm.GetAuth().GetAllowedGroupIds(), "an empty list must not restrict access") + }) +} + func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) { rp := &Service{ ID: "svc-1", diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index cee50b270..2fc969ad0 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -1651,6 +1651,10 @@ var ( // ErrUserBlocked reports a blocked user, who may not hold a proxy session. ErrUserBlocked = errors.New("user blocked") + // ErrUserNotInGroup reports a user outside the service's distribution + // groups, who may not hold a proxy session for it. + ErrUserNotInGroup = errors.New("user not in allowed groups") + errUserUnresolved = errors.New("user could not be resolved") ) @@ -1689,8 +1693,10 @@ func sameAccount(userAccountID, serviceAccountID string) bool { // GenerateSessionToken creates a signed session JWT for the given domain and // user. The user's group memberships are embedded in the token so policy-aware // middlewares on the proxy can authorise without an extra management round-trip. -// A user the store cannot resolve, or whose account is pending approval or -// blocked, gets no token at all, so the browser never receives a session cookie. +// A user the store cannot resolve, whose account is pending approval or blocked, +// or who is outside the service's distribution groups, gets no token at all: the +// token is a bearer credential for the service, so authorisation has to run +// before it is signed rather than only when the proxy presents it back. func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) { service, err := s.getServiceByDomain(ctx, domain) if err != nil { @@ -1726,6 +1732,14 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u return "", fmt.Errorf("session token for user %s: %w", userID, err) } + if err := s.checkGroupAccess(service, user); err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "domain": domain, + "user_id": userID, + }).Debug("GenerateSessionToken: user not in the service's distribution groups") + return "", fmt.Errorf("session token for user %s: %w", userID, ErrUserNotInGroup) + } + groupIDs, groupNames := pairGroupIDsAndNames(userGroups) token, err := sessionkey.SignToken( diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index 03f200414..4e70e61e4 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -431,6 +431,57 @@ func TestValidateSession_MissingToken(t *testing.T) { assert.Contains(t, resp.DeniedReason, "missing") } +// TestGenerateSessionToken_UserNotInAllowedGroupGetsNoToken is the regression +// guard for the group-authorisation bypass: the callback used to hand a signed +// token to a user the service denies, and the proxy honoured that token as soon +// as the user moved it into the nb_session cookie themselves. Authorisation has +// to run before the token is signed. +func TestGenerateSessionToken_UserNotInAllowedGroupGetsNoToken(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + token, err := setup.proxyService.GenerateSessionToken(context.Background(), "restricted-proxy.example.com", "nonGroupUserId", auth.MethodOIDC) + + require.Error(t, err, "a user outside the distribution groups must not receive a token") + assert.ErrorIs(t, err, ErrUserNotInGroup, "the callback maps this sentinel onto the access denied page") + assert.Empty(t, token, "no token may reach the browser") +} + +func TestGenerateSessionToken_UserInAllowedGroupGetsTokenWithGroups(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + ctx := context.Background() + svc, err := setup.store.GetServiceByID(ctx, store.LockingStrengthNone, "testAccountId", "restrictedProxyId") + require.NoError(t, err) + + token, err := setup.proxyService.GenerateSessionToken(ctx, "restricted-proxy.example.com", "allowedUserId", auth.MethodOIDC) + require.NoError(t, err) + require.NotEmpty(t, token) + + pubKey, err := base64.StdEncoding.DecodeString(svc.SessionPublicKey) + require.NoError(t, err) + + userID, _, method, groups, _, err := auth.ValidateSessionJWT(token, "restricted-proxy.example.com", pubKey) + require.NoError(t, err) + assert.Equal(t, "allowedUserId", userID) + assert.Equal(t, auth.MethodOIDC.String(), method) + assert.Equal(t, []string{"allowedGroupId"}, groups, "the proxy gates the cookie on this claim, so it must carry the matched group") +} + +// TestGenerateSessionToken_UnrestrictedServiceAllowsAnyAccountUser keeps the new +// gate scoped: a service without distribution groups is open to every user of +// its account, as before. +func TestGenerateSessionToken_UnrestrictedServiceAllowsAnyAccountUser(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + token, err := setup.proxyService.GenerateSessionToken(context.Background(), "test-proxy.example.com", "nonGroupUserId", auth.MethodOIDC) + + require.NoError(t, err, "an unrestricted service must keep working for any user of the account") + assert.NotEmpty(t, token) +} + type testValidateSessionServiceManager struct { store store.Store } diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go index 62725e8d4..0f4b72e14 100644 --- a/management/server/http/handlers/proxy/auth.go +++ b/management/server/http/handlers/proxy/auth.go @@ -100,9 +100,10 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ return } - // Group validation is performed by the proxy via ValidateSession gRPC call. - // This allows the proxy to show 403 pages directly without redirect dance. - + // GenerateSessionToken applies the service's group and account-status gates, + // so a user without access never receives a token. The proxy re-checks the + // installed cookie against the service's allowed groups, and renders the + // denial page from the error carried back in the redirect. sessionToken, err := h.proxyService.GenerateSessionToken(r.Context(), redirectURL.Hostname(), userID, auth.MethodOIDC) if err != nil { log.WithError(err).Error("Failed to create session token") @@ -136,6 +137,9 @@ func sessionTokenErrorDescription(err error) string { if errors.Is(err, nbgrpc.ErrUserBlocked) { return "Your account is blocked" } + if errors.Is(err, nbgrpc.ErrUserNotInGroup) { + return "You are not authorized to access this service" + } return "Service configuration error" } diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go index 8abdf2923..1d46fd824 100644 --- a/proxy/internal/auth/middleware.go +++ b/proxy/internal/auth/middleware.go @@ -59,6 +59,11 @@ type DomainConfig struct { IPRestrictions *restrict.Filter // Private routes the domain through ValidateTunnelPeer; failure → 403. Private bool + // AllowedGroups holds the group ids that may reach the service through an + // OIDC identity. When non-empty, a session cookie is honoured only if its + // groups claim intersects this set. Empty means group membership does not + // restrict access. + AllowedGroups map[string]struct{} } type validationResult struct { @@ -316,6 +321,9 @@ func (mw *Middleware) handleOAuthCallbackError(w http.ResponseWriter, r *http.Re // forwardWithSessionCookie checks for a valid session cookie and, if found, // sets the user identity on the request context and forwards to the next handler. +// A signature-valid cookie is not on its own a grant: an OIDC session must also +// carry a group the service allows, so a token cannot be replayed past the +// group check that gated the login it came from. func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool { cookie, err := r.Cookie(auth.SessionCookieName) if err != nil { @@ -335,6 +343,14 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re return false } + if !sessionGroupsAllowed(config.AllowedGroups, auth.Method(method), groups) { + mw.logger.WithFields(log.Fields{ + "host": host, + "user_id": userID, + }).Debug("session cookie rejected: groups claim does not intersect the service's allowed groups") + return false + } + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { cd.SetUserID(userID) cd.SetUserEmail(email) @@ -625,7 +641,8 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { // AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required. // private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list. -func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error { +// allowedGroups restricts OIDC sessions to the given group ids; empty means unrestricted. +func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool, allowedGroups []string) error { if len(schemes) == 0 { mw.domainsMux.Lock() defer mw.domainsMux.Unlock() @@ -634,6 +651,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st ServiceID: serviceID, IPRestrictions: ipRestrictions, Private: private, + AllowedGroups: groupSet(allowedGroups), } return nil } @@ -656,6 +674,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st ServiceID: serviceID, IPRestrictions: ipRestrictions, Private: private, + AllowedGroups: groupSet(allowedGroups), } return nil } @@ -707,6 +726,50 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri return &validationResult{UserID: userID, UserEmail: email, Valid: true, Groups: groups, GroupNames: groupNames}, nil } +// groupSet builds the lookup set the cookie path consults, returning nil for an +// empty list so callers can test membership restriction with len(). +func groupSet(groups []string) map[string]struct{} { + if len(groups) == 0 { + return nil + } + set := make(map[string]struct{}, len(groups)) + for _, g := range groups { + if g != "" { + set[g] = struct{}{} + } + } + if len(set) == 0 { + return nil + } + return set +} + +// sessionGroupsAllowed reports whether a session token's groups claim satisfies +// the service's allowed groups. Only OIDC sessions are gated: password, PIN and +// header credentials carry no group identity and are authorised by the secret +// itself, which mirrors how management validates them. A token minted before the +// groups claim existed carries none and is therefore denied on a group-restricted +// service, which sends the user back through login for a fresh decision. A method +// this build doesn't know carries no such argument, so it is denied. +func sessionGroupsAllowed(allowed map[string]struct{}, method auth.Method, groups []string) bool { + if len(allowed) == 0 { + return true + } + switch method { + case auth.MethodPassword, auth.MethodPIN, auth.MethodHeader: + return true + case auth.MethodOIDC: + for _, g := range groups { + if _, ok := allowed[g]; ok { + return true + } + } + return false + default: + return false + } +} + // stripSessionTokenParam returns the request URI with the session_token query // parameter removed so it doesn't linger in the browser's address bar or history. func stripSessionTokenParam(u *url.URL) string { diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go index 9220ce790..f1242f95e 100644 --- a/proxy/internal/auth/middleware_test.go +++ b/proxy/internal/auth/middleware_test.go @@ -66,7 +66,7 @@ func TestAddDomain_ValidKey(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false) + err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil) require.NoError(t, err) mw.domainsMux.RLock() @@ -83,7 +83,7 @@ func TestAddDomain_EmptyKey(t *testing.T) { mw := NewMiddleware(log.StandardLogger(), nil, nil) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false) + err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false, nil) require.Error(t, err) assert.Contains(t, err.Error(), "invalid session public key size") @@ -97,7 +97,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) { mw := NewMiddleware(log.StandardLogger(), nil, nil) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false) + err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false, nil) require.Error(t, err) assert.Contains(t, err.Error(), "decode session public key") @@ -112,7 +112,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) { shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort")) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false) + err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false, nil) require.Error(t, err) assert.Contains(t, err.Error(), "invalid session public key size") @@ -125,7 +125,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) { func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) { mw := NewMiddleware(log.StandardLogger(), nil, nil) - err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false) + err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false, nil) require.NoError(t, err, "domains with no auth schemes should not require a key") mw.domainsMux.RLock() @@ -141,8 +141,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) { scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false)) - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false, nil)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false, nil)) mw.domainsMux.RLock() config := mw.domains["example.com"] @@ -158,7 +158,7 @@ func TestRemoveDomain(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) mw.RemoveDomain("example.com") @@ -182,7 +182,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) { func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) { mw := NewMiddleware(log.StandardLogger(), nil, nil) - require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -199,7 +199,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) var backendCalled bool backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -220,7 +220,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) var backendCalled bool backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -241,7 +241,7 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour) require.NoError(t, err) @@ -274,7 +274,7 @@ func TestProtect_SessionCookieGroupsPropagate(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) groups := []string{"engineering", "sre"} token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour) @@ -339,7 +339,7 @@ func TestProtect_PrivateService_TunnelPeerGroupsPropagate(t *testing.T) { kp := generateTestKeyPair(t) // Private service: no operator schemes — auth gates solely on the tunnel peer. - require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true)) + require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true, nil)) cd := proxy.NewCapturedData("") cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source @@ -379,7 +379,7 @@ func TestProtect_PrivateService_TunnelPeerDenied(t *testing.T) { }} mw := NewMiddleware(log.StandardLogger(), validator, nil) kp := generateTestKeyPair(t) - require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true)) + require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true, nil)) cd := proxy.NewCapturedData("") cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) @@ -407,7 +407,7 @@ func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) // Sign a token that expired 1 second ago. token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second) @@ -433,7 +433,7 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) // Token signed for a different domain audience. token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour) @@ -460,7 +460,7 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) { kp2 := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false, nil)) // Token signed with a different private key. token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour) @@ -497,7 +497,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) { return "", "pin", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) var backendCalled bool backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -550,7 +550,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) { return "", "pin", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -586,7 +586,7 @@ func TestProtect_MultipleSchemes(t *testing.T) { return "", "password", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) var backendCalled bool backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -616,7 +616,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) { return "invalid-jwt-token", "", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -640,7 +640,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) { key := base64.StdEncoding.EncodeToString(randomBytes) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false) + err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false, nil) require.NoError(t, err, "any 32-byte key should be accepted at registration time") } @@ -649,10 +649,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) // Attempt to overwrite with an invalid key. - err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false) + err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false, nil) require.Error(t, err) // The original valid config should still be intact. @@ -676,7 +676,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) { return "", "pin", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) capturedData := proxy.NewCapturedData("") handler := mw.Protect(newPassthroughHandler()) @@ -703,7 +703,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) { return "", "password", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) capturedData := proxy.NewCapturedData("") handler := mw.Protect(newPassthroughHandler()) @@ -730,7 +730,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) { return "", "pin", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) capturedData := proxy.NewCapturedData("") handler := mw.Protect(newPassthroughHandler()) @@ -818,7 +818,7 @@ func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) { mw := NewMiddleware(log.StandardLogger(), nil, nil) err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", - restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false) + restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false, nil) require.NoError(t, err) handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -854,7 +854,7 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) { mw := NewMiddleware(log.StandardLogger(), nil, nil) err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", - restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false) + restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false, nil) require.NoError(t, err) handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -895,7 +895,7 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) { mw := NewMiddleware(log.StandardLogger(), nil, nil) err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", - restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false) + restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false, nil) require.NoError(t, err) handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -922,7 +922,7 @@ func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) { restrict.ParseFilter(restrict.FilterConfig{ AllowedCIDRs: []string{"100.64.0.0/10"}, AllowedCountries: []string{"US"}, - }), false) + }), false, nil) require.NoError(t, err) handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -956,7 +956,7 @@ func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) { mw := NewMiddleware(log.StandardLogger(), nil, nil) err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", - restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false) + restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false, nil) require.NoError(t, err) handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -984,7 +984,7 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) { return "", oidcURL, nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -1013,7 +1013,7 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) { return "", "pin", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -1043,7 +1043,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) { kp := generateTestKeyPair(t) hdr := newHeaderScheme(t, "X-API-Key", "secret-key") - require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) var backendCalled bool capturedData := proxy.NewCapturedData("") @@ -1079,7 +1079,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) { hdr := newHeaderScheme(t, "X-API-Key", "secret-key") // Also add a PIN scheme so we can verify fallthrough behavior. pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -1096,7 +1096,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) { kp := generateTestKeyPair(t) hdr := newHeaderScheme(t, "X-API-Key", "secret-key") - require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) capturedData := proxy.NewCapturedData("") handler := mw.Protect(newPassthroughHandler()) @@ -1137,7 +1137,7 @@ func TestProtect_HeaderAuth_MatchesAnyConfiguredHeader(t *testing.T) { if tt.matchedLast { schemes = []Scheme{authz, apiKey} } - require.NoError(t, mw.AddDomain("example.com", schemes, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", schemes, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) var backendCalled bool handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -1166,7 +1166,7 @@ func TestProtect_HeaderAuth_RejectsWhenEveryPresentedHeaderFails(t *testing.T) { authz := newHeaderScheme(t, "Authorization", "Bearer proxy-secret") apiKey := newHeaderScheme(t, "X-Api-Key", "secret-key") - require.NoError(t, mw.AddDomain("example.com", []Scheme{authz, apiKey}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{authz, apiKey}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) var backendCalled bool handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -1209,7 +1209,7 @@ func TestProtect_HeaderAuth_ReportsUndecodableHash(t *testing.T) { kp := generateTestKeyPair(t) require.NoError(t, mw.AddDomain("example.com", []Scheme{NewHeader("X-Api-Key", tt.hashes)}, - kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -1245,7 +1245,7 @@ func TestProtect_HeaderAuth_NoHashesFailsClosed(t *testing.T) { kp := generateTestKeyPair(t) hdr := NewHeader("X-API-Key", nil) - require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) var backendCalled bool handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -1270,7 +1270,7 @@ func TestProtect_HeaderAuth_SubsequentRequestRequiresHeader(t *testing.T) { kp := generateTestKeyPair(t) hdr := newHeaderScheme(t, "X-API-Key", "secret-key") - require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) var backendCalls int handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -1309,7 +1309,7 @@ func TestProtect_HeaderAuth_LegacySessionCookieIsIgnored(t *testing.T) { kp := generateTestKeyPair(t) hdr := newHeaderScheme(t, "X-API-Key", "secret-key") - require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) // A token management would have minted for header auth before the upgrade. legacyToken, err := sessionkey.SignToken(kp.PrivateKey, auth.HeaderUserID, "", "example.com", auth.MethodHeader, nil, nil, time.Hour) @@ -1351,7 +1351,7 @@ func TestProtect_HeaderAuth_RepeatedValueIsMemoized(t *testing.T) { kp := generateTestKeyPair(t) hdr := newHeaderScheme(t, "X-API-Key", "key-a", "key-b") - require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) @@ -1385,7 +1385,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) { kp := generateTestKeyPair(t) hdr := newHeaderScheme(t, "Authorization", "Bearer token-a", "Bearer token-b") - require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil)) var backendCalled bool handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -1443,7 +1443,7 @@ func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) { return "", "https://idp.example.com/authorize", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -1467,7 +1467,7 @@ func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) { return "", "https://idp.example.com/authorize", nil }, } - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -1487,7 +1487,7 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -1517,7 +1517,7 @@ func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) @@ -1552,7 +1552,7 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) { kp := generateTestKeyPair(t) scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} - require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)) handler := mw.Protect(newPassthroughHandler()) diff --git a/proxy/internal/auth/session_groups_test.go b/proxy/internal/auth/session_groups_test.go new file mode 100644 index 000000000..6635b812b --- /dev/null +++ b/proxy/internal/auth/session_groups_test.go @@ -0,0 +1,167 @@ +package auth + +import ( + "context" + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// denyingSessionValidator mimics management for a user who completed OIDC login +// but is outside the service's distribution groups: ValidateSession denies. +type denyingSessionValidator struct { + calls int +} + +func (d *denyingSessionValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) { + d.calls++ + return &proto.ValidateSessionResponse{Valid: false, UserId: "user-1", DeniedReason: "not_in_group"}, nil +} + +func (d *denyingSessionValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) { + return &proto.ValidateTunnelPeerResponse{Valid: false}, nil +} + +// TestProtect_SelfInstalledCookieCannotBypassGroupCheck is the regression guard +// for the group-authorisation bypass: a user denied at login still holds the raw +// session token from the ?session_token= redirect, so pasting it into the +// nb_session cookie must not buy access. The cookie path validated only the JWT +// signature, which turned the token management had already refused into a bearer +// credential for the service. +func TestProtect_SelfInstalledCookieCannotBypassGroupCheck(t *testing.T) { + validator := &denyingSessionValidator{} + mw := NewMiddleware(log.StandardLogger(), validator, nil) + kp := generateTestKeyPair(t) + + oidc := &stubScheme{method: auth.MethodOIDC, authFn: func(r *http.Request) (string, string, error) { + return r.URL.Query().Get("session_token"), "https://idp.example/authorize", nil + }} + require.NoError(t, mw.AddDomain("example.com", []Scheme{oidc}, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, false, []string{"grp-allowed"})) + + // The token a denied user gets to see: validly signed for this service and + // domain, but carrying no group the service allows. + token, err := sessionkey.SignToken(kp.PrivateKey, "user-1", "john.doe@example.com", "example.com", auth.MethodOIDC, nil, nil, time.Hour) + require.NoError(t, err) + + backendHits := 0 + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendHits++ + w.WriteHeader(http.StatusOK) + })) + + t.Run("token in the callback URL is denied", func(t *testing.T) { + rec := serveWithCookie(t, handler, "https://example.com/?session_token="+token, nil) + + assert.Equal(t, http.StatusForbidden, rec.Code, "group check must deny the login") + assert.Empty(t, rec.Result().Cookies(), "a denied login must not install a session cookie") + }) + + t.Run("same token pasted into the session cookie is denied", func(t *testing.T) { + rec := serveWithCookie(t, handler, "https://example.com/", &http.Cookie{Name: auth.SessionCookieName, Value: token}) + + assert.NotEqual(t, http.StatusOK, rec.Code, "a self-installed cookie must not reach the backend") + assert.Equal(t, 0, backendHits, "backend must never be reached without an allowed group") + }) +} + +// TestProtect_SessionCookieWithAllowedGroupPassesThrough is the positive half of +// the group gate: a member of an allowed group keeps the cookie fast-path, with +// no management round-trip. +func TestProtect_SessionCookieWithAllowedGroupPassesThrough(t *testing.T) { + validator := &denyingSessionValidator{} + mw := NewMiddleware(log.StandardLogger(), validator, nil) + kp := generateTestKeyPair(t) + + oidc := &stubScheme{method: auth.MethodOIDC} + require.NoError(t, mw.AddDomain("example.com", []Scheme{oidc}, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, false, []string{"grp-other", "grp-allowed"})) + + token, err := sessionkey.SignToken(kp.PrivateKey, "user-2", "jane@example.com", "example.com", auth.MethodOIDC, + []string{"grp-unrelated", "grp-allowed"}, []string{"Unrelated", "Allowed"}, time.Hour) + require.NoError(t, err) + + handler := mw.Protect(newPassthroughHandler()) + rec := serveWithCookie(t, handler, "https://example.com/", &http.Cookie{Name: auth.SessionCookieName, Value: token}) + + assert.Equal(t, http.StatusOK, rec.Code, "a cookie carrying an allowed group must pass through") + assert.Equal(t, 0, validator.calls, "the cookie fast-path must not call management") +} + +// TestProtect_NonOIDCSessionCookieIgnoresGroupRestriction locks the scope of the +// gate: PIN, password and header credentials carry no group identity and are +// authorised by the secret itself, exactly as management validates them. +func TestProtect_NonOIDCSessionCookieIgnoresGroupRestriction(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, false, []string{"grp-allowed"})) + + token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour) + require.NoError(t, err) + + handler := mw.Protect(newPassthroughHandler()) + rec := serveWithCookie(t, handler, "https://example.com/", &http.Cookie{Name: auth.SessionCookieName, Value: token}) + + assert.Equal(t, http.StatusOK, rec.Code, "a PIN session must not be gated on OIDC group membership") +} + +func TestSessionGroupsAllowed(t *testing.T) { + allowed := groupSet([]string{"a", "b"}) + + tests := []struct { + name string + allowed map[string]struct{} + method auth.Method + groups []string + want bool + }{ + {"unrestricted service allows a groupless token", nil, auth.MethodOIDC, nil, true}, + {"restricted service allows an intersecting token", allowed, auth.MethodOIDC, []string{"c", "b"}, true}, + {"restricted service denies a disjoint token", allowed, auth.MethodOIDC, []string{"c"}, false}, + {"restricted service denies a groupless token", allowed, auth.MethodOIDC, nil, false}, + {"restricted service ignores a pin token", allowed, auth.MethodPIN, nil, true}, + {"restricted service ignores a password token", allowed, auth.MethodPassword, nil, true}, + {"restricted service ignores a header token", allowed, auth.MethodHeader, nil, true}, + {"restricted service denies an unknown method", allowed, auth.Method("totp"), []string{"a"}, false}, + {"restricted service denies a token with no method", allowed, auth.Method(""), []string{"a"}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, sessionGroupsAllowed(tc.allowed, tc.method, tc.groups)) + }) + } +} + +func TestGroupSetDropsEmptyEntries(t *testing.T) { + assert.Nil(t, groupSet(nil), "no groups means unrestricted") + assert.Nil(t, groupSet([]string{"", ""}), "blank ids must not restrict access to nothing reachable") + assert.Equal(t, map[string]struct{}{"a": {}}, groupSet([]string{"a", ""})) +} + +// serveWithCookie drives the middleware over TLS with captured data attached, +// optionally carrying a session cookie. +func serveWithCookie(t *testing.T, handler http.Handler, url string, cookie *http.Cookie) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, url, nil) + req.TLS = &tls.ConnectionState{} + if cookie != nil { + req.AddCookie(cookie) + } + req = req.WithContext(proxy.WithCapturedData(req.Context(), proxy.NewCapturedData(""))) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} diff --git a/proxy/internal/auth/tunnel_lookup_test.go b/proxy/internal/auth/tunnel_lookup_test.go index 808aa8b41..066f5dc1a 100644 --- a/proxy/internal/auth/tunnel_lookup_test.go +++ b/proxy/internal/auth/tunnel_lookup_test.go @@ -44,7 +44,7 @@ func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.V func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware { t.Helper() mw := NewMiddleware(log.New(), validator, nil) - require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false)) + require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false, nil)) return mw } @@ -235,8 +235,8 @@ func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) { } mw := NewMiddleware(log.New(), validator, nil) - require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false)) - require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false)) + require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false, nil)) + require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false, nil)) // The fast-path requires the inbound-listener marker on the context. // The peerstore lookup itself is account-agnostic at this level @@ -299,7 +299,7 @@ func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *te func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) { mw := NewMiddleware(log.New(), nil, nil) - require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true)) + require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true, nil)) called := false handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -328,7 +328,7 @@ func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) { }, } mw := NewMiddleware(log.New(), validator, nil) - require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true)) + require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true, nil)) called := false handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index cb82813b0..df016e790 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -571,6 +571,7 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T proxytypes.ServiceID(mapping.GetId()), nil, mapping.GetPrivate(), + mapping.GetAuth().GetAllowedGroupIds(), ) require.NoError(t, err) diff --git a/proxy/server.go b/proxy/server.go index 38477fb87..5b652e61c 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -2069,7 +2069,7 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second - if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil { + if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate(), mapping.GetAuth().GetAllowedGroupIds()); err != nil { return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err) } m := s.protoToMapping(ctx, mapping) diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go index df42d78ff..496774a4b 100644 --- a/shared/management/proto/proxy_service.pb.go +++ b/shared/management/proto/proxy_service.pb.go @@ -903,6 +903,12 @@ type Authentication struct { Pin bool `protobuf:"varint,4,opt,name=pin,proto3" json:"pin,omitempty"` Oidc bool `protobuf:"varint,5,opt,name=oidc,proto3" json:"oidc,omitempty"` HeaderAuths []*HeaderAuth `protobuf:"bytes,6,rep,name=header_auths,json=headerAuths,proto3" json:"header_auths,omitempty"` + // Group ids allowed to reach the service through an OIDC identity. When + // non-empty the proxy requires the session token's groups claim to + // intersect this list before honouring the cookie, so a token minted for + // an identity outside these groups is not a bearer credential for the + // service. Empty means group membership does not restrict access. + AllowedGroupIds []string `protobuf:"bytes,7,rep,name=allowed_group_ids,json=allowedGroupIds,proto3" json:"allowed_group_ids,omitempty"` } func (x *Authentication) Reset() { @@ -979,6 +985,13 @@ func (x *Authentication) GetHeaderAuths() []*HeaderAuth { return nil } +func (x *Authentication) GetAllowedGroupIds() []string { + if x != nil { + return x.AllowedGroupIds + } + return nil +} + type AccessRestrictions struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -3304,7 +3317,7 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x61, 0x73, 0x68, 0x65, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, - 0x61, 0x73, 0x68, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0xe5, 0x01, 0x0a, 0x0e, 0x41, + 0x61, 0x73, 0x68, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x91, 0x02, 0x0a, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x35, @@ -3319,198 +3332,220 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, - 0x68, 0x73, 0x22, 0xdd, 0x01, 0x0a, 0x12, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, - 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x6c, 0x6c, - 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x0c, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x69, 0x64, 0x72, 0x73, 0x12, 0x23, - 0x0a, 0x0d, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x43, 0x69, - 0x64, 0x72, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, - 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, - 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, - 0x12, 0x2b, 0x0a, 0x11, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x62, 0x6c, 0x6f, - 0x63, 0x6b, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, - 0x0d, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x4d, 0x6f, - 0x64, 0x65, 0x22, 0x80, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x12, 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, - 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, - 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x12, 0x2b, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, - 0x74, 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, - 0x1d, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, - 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, - 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, 0x12, 0x28, - 0x0a, 0x10, 0x70, 0x61, 0x73, 0x73, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x64, - 0x65, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x70, 0x61, 0x73, 0x73, 0x48, 0x6f, - 0x73, 0x74, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, 0x77, 0x72, - 0x69, 0x74, 0x65, 0x5f, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x73, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x0a, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, - 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, - 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x4f, 0x0a, 0x13, 0x61, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x5f, 0x72, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, - 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x12, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, - 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, - 0x69, 0x76, 0x61, 0x74, 0x65, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, - 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, - 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, - 0x67, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0xa9, 0x05, 0x0a, 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, - 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 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, 0x09, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, - 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, - 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, - 0x68, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, - 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x70, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, - 0x0d, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, - 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, - 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, - 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, - 0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, - 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, - 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, - 0x21, 0x0a, 0x0c, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, - 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, - 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, - 0x61, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, - 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, - 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, - 0x79, 0x74, 0x65, 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, - 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, - 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x1a, 0x3b, - 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, - 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, - 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, - 0x64, 0x65, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, - 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, - 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x57, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, - 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, - 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, - 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, - 0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, - 0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, - 0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, - 0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, - 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, - 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xda, 0x02, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, - 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, - 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, - 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, - 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, - 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, 0x6e, 0x62, - 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, 0x32, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, - 0x74, 0x65, 0x6e, 0x65, 0x72, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, - 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x13, 0x0a, - 0x11, 0x5f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, - 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, - 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x73, - 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, 0x74, 0x74, - 0x70, 0x73, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, 0x5f, 0x70, - 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, 0x70, 0x50, - 0x6f, 0x72, 0x74, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, - 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, - 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, - 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, - 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, - 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, - 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, - 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, - 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, - 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, - 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, - 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, - 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, - 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 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, 0x12, 0x23, 0x0a, 0x0d, + 0x68, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x61, + 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0xdd, + 0x01, 0x0a, 0x12, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, + 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x69, 0x64, 0x72, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x62, 0x6c, + 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x43, 0x69, 0x64, 0x72, 0x73, 0x12, + 0x2b, 0x0a, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x72, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x61, 0x6c, 0x6c, 0x6f, + 0x77, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x2b, 0x0a, 0x11, + 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, + 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x72, 0x6f, + 0x77, 0x64, 0x73, 0x65, 0x63, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0c, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x4d, 0x6f, 0x64, 0x65, 0x22, 0x80, + 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, + 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, + 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, + 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2b, + 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x4d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1d, 0x0a, 0x0a, 0x61, + 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, 0x0a, 0x04, 0x61, 0x75, + 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x61, + 0x73, 0x73, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x70, 0x61, 0x73, 0x73, 0x48, 0x6f, 0x73, 0x74, 0x48, 0x65, + 0x61, 0x64, 0x65, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, + 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x10, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, + 0x70, 0x6f, 0x72, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, + 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x4f, 0x0a, 0x13, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x5f, 0x72, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x12, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, + 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, + 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f, 0x67, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, 0x6c, + 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xa9, 0x05, 0x0a, 0x09, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 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, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, + 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, + 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, + 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1b, + 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, + 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, + 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, + 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x21, + 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0e, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f, 0x61, + 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e, 0x6c, + 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, + 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x2e, 0x4d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, + 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, + 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x39, + 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, + 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70, 0x69, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, + 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, + 0x61, 0x75, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, + 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x68, 0x65, 0x61, + 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x22, 0x57, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, + 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, + 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x65, + 0x61, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a, 0x0f, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, + 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, + 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, 0x14, 0x41, 0x75, + 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x22, 0xdc, 0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, + 0x6e, 0x22, 0xda, 0x02, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, + 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, + 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, + 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, + 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x0d, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, + 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, 0x32, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, + 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, + 0x72, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, + 0x65, 0x6e, 0x65, 0x72, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, + 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x22, 0x6f, + 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, + 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, + 0x6c, 0x49, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x73, 0x5f, 0x70, 0x6f, 0x72, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, 0x74, 0x74, 0x70, 0x73, 0x50, 0x6f, + 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, + 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, + 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, + 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, + 0x61, 0x72, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, + 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, + 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, + 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, + 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, + 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, + 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 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, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xdc, 0x01, + 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, + 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, + 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, + 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, + 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, + 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, + 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, + 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, + 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x19, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, + 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, + 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, + 0x02, 0x0a, 0x1a, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, + 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, @@ -3518,208 +3553,189 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, - 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, - 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, - 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, - 0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, - 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, - 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, - 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, - 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, - 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, - 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, - 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, - 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, - 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, - 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, - 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, - 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, - 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, - 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, - 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, - 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 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, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, - 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, - 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, - 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, - 0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, - 0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, - 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, - 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, - 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, - 0x74, 0x65, 0x22, 0xa9, 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, - 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, - 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 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, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, - 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, - 0x01, 0x0a, 0x1c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x1a, 0x0a, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, - 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, - 0x64, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, - 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, - 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, - 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, - 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, - 0x64, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, - 0x1f, 0x0a, 0x0b, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x22, 0x91, 0x02, 0x0a, 0x15, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, - 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, - 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, - 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, - 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, - 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, - 0x63, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, - 0x63, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, - 0x70, 0x49, 0x64, 0x73, 0x22, 0x18, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, - 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, - 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, - 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, - 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, - 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, - 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, - 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, - 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, - 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, - 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, - 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, - 0x0e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, - 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, - 0x4f, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, - 0x12, 0x1e, 0x0a, 0x1a, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, - 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, - 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, - 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, - 0x02, 0x12, 0x1c, 0x0a, 0x18, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, - 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, - 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, - 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, - 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, - 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, - 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, - 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, - 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, - 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, - 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, - 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, - 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, - 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, - 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, - 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, - 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, - 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, - 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, - 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, - 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, - 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, - 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, - 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, - 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, - 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, - 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, + 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, + 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, + 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, + 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, + 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, + 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, + 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, + 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, + 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, + 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, + 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, + 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, + 0x74, 0x18, 0x03, 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, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, + 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, + 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, + 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, + 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, + 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, + 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, + 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, + 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, + 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xa9, + 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, + 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, + 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, + 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 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, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, 0x1c, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, + 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, + 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, + 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, + 0x74, 0x65, 0x64, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x6c, + 0x69, 0x63, 0x79, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, + 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, + 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1b, + 0x0a, 0x09, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x64, 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, + 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0a, 0x64, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, 0x02, 0x0a, + 0x15, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, + 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, + 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, + 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x49, 0x6e, + 0x70, 0x75, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x6f, 0x75, + 0x74, 0x70, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, 0x73, 0x74, + 0x5f, 0x75, 0x73, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, 0x73, 0x74, + 0x55, 0x73, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, + 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, + 0x22, 0x18, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, + 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, + 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, + 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, + 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, + 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, + 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, + 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, + 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, + 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, + 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, + 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, + 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, + 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, + 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, + 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, + 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, + 0x18, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, + 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, + 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, + 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, + 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, + 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, + 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, + 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, + 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, + 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, + 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, + 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, + 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, + 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, + 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, + 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, + 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, + 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, + 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, + 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, + 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, + 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, + 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, + 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, - 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, + 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, + 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, + 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, - 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, - 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, - 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, - 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, - 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, - 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, - 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x57, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, - 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, + 0x65, 0x72, 0x12, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, + 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, + 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x69, 0x0a, 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, + 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, + 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, + 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 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/proxy_service.proto b/shared/management/proto/proxy_service.proto index 89d1f8749..facadc4d5 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -194,6 +194,12 @@ message Authentication { bool pin = 4; bool oidc = 5; repeated HeaderAuth header_auths = 6; + // Group ids allowed to reach the service through an OIDC identity. When + // non-empty the proxy requires the session token's groups claim to + // intersect this list before honouring the cookie, so a token minted for + // an identity outside these groups is not a bearer credential for the + // service. Empty means group membership does not restrict access. + repeated string allowed_group_ids = 7; } message AccessRestrictions { From 8dc427251996feaed8ec32b0962131a4725fe02b Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 4 Sep 2026 08:14:02 +0200 Subject: [PATCH 06/63] [management] Serve networks with peer-based routers from the SQLite network map (#7418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLite network-map query expanded a router's groups with from network_routers, json_each(peer_groups). That comma is an inner join, so a router row survives only when json_each returns at least one row. A router targeting an individual peer carries no groups — the write path stores NULL for a nil slice and '[]' for an empty one — and json_each yields nothing for either, so the join erased the router before it could be keyed by its peer. Postgres reads the same rows through a correlated subquery and was never affected. The fix expands the groups with a left join, so the router survives with a NULL group_peers.peer_id and the existing scan loop keys it by router.Peer. Group routers still fan out one row per member. --- .../network_router_store_test.go | 120 ++++++++++++++++++ .../network_map_db/sqlite/network_router.go | 4 +- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 management/internals/network_map_db/network_router_store_test.go diff --git a/management/internals/network_map_db/network_router_store_test.go b/management/internals/network_map_db/network_router_store_test.go new file mode 100644 index 000000000..d44aa1ebc --- /dev/null +++ b/management/internals/network_map_db/network_router_store_test.go @@ -0,0 +1,120 @@ +package networkmapdb_test + +import ( + "context" + "net/netip" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + 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" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/testutil" + "github.com/netbirdio/netbird/management/server/types" +) + +// newEngineStores opens both stores on the selected engine's database. +func newEngineStores(t *testing.T) (store.Store, networkmapdb.NetworkMapDBStore) { + t.Helper() + ctx := context.Background() + + switch engine := types.Engine(os.Getenv("NETBIRD_STORE_ENGINE")); engine { + case types.PostgresStoreEngine: + cleanup, dsn, err := testutil.CreatePostgresTestContainer() + require.NoError(t, err, "start postgres test container") + t.Cleanup(cleanup) + + accountStore, err := store.NewPostgresqlStore(ctx, dsn, nil, false) + require.NoError(t, err, "connect account store") + t.Cleanup(func() { _ = accountStore.Close(ctx) }) + + nmStore, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn) + require.NoError(t, err, "connect networkmap store") + t.Cleanup(func() { nmStore.Pool.Close() }) + return accountStore, nmStore + case types.SqliteStoreEngine, "": + dataDir := t.TempDir() + accountStore, err := store.NewSqliteStore(ctx, dataDir, nil, false) + require.NoError(t, err, "open account store") + t.Cleanup(func() { _ = accountStore.Close(ctx) }) + + nmStore, err := networkmap_sqlite.NewSqliteStore("store.db", dataDir) + require.NoError(t, err, "open networkmap store") + t.Cleanup(func() { _ = nmStore.Db.Close() }) + return accountStore, nmStore + default: + t.Skipf("networkmap store does not support engine %q", engine) + return nil, nil + } +} + +// Peer-based routers must survive the network-map read on every engine. +func TestGetNetworkRouters_ServesPeerBasedRouters(t *testing.T) { + ctx := context.Background() + accountStore, nmStore := newEngineStores(t) + + const ( + accountID = "acc-nmap-routers" + groupID = "grp-router-members" + memberID = "peer-member" + ) + + // Postgres enforces the groups-to-accounts FK that SQLite ignores. + require.NoError(t, accountStore.SaveAccount(ctx, &types.Account{ + Id: accountID, + Peers: map[string]*nbpeer.Peer{ + memberID: { + ID: memberID, + AccountID: accountID, + Key: memberID + "-key", + IP: netip.MustParseAddr("100.64.0.10"), + Status: &nbpeer.PeerStatus{}, + }, + }, + Groups: map[string]*types.Group{ + groupID: { + ID: groupID, + AccountID: accountID, + Name: "router members", + Issued: types.GroupIssuedAPI, + Peers: []string{memberID}, + }, + }, + }), "seed the account the routers belong to") + + routers := []*routerTypes.NetworkRouter{ + {ID: "router-peer-nil", AccountID: accountID, NetworkID: "net-peer-nil", PublicID: "pub-peer-nil", Peer: "peer-direct-nil", Enabled: true, Metric: 9999}, + {ID: "router-peer-empty", AccountID: accountID, NetworkID: "net-peer-empty", PublicID: "pub-peer-empty", Peer: "peer-direct-empty", PeerGroups: []string{}, Enabled: true, Metric: 9999}, + {ID: "router-group", AccountID: accountID, NetworkID: "net-group", PublicID: "pub-group", PeerGroups: []string{groupID}, Enabled: true, Metric: 9999}, + } + for _, router := range routers { + require.NoError(t, accountStore.CreateNetworkRouter(ctx, router)) + } + + tx, err := nmStore.BeginTx(ctx) + require.NoError(t, err, "begin networkmap read transaction") + t.Cleanup(func() { _ = tx.RollbackTx(ctx) }) + + got, err := tx.GetNetworkRouters(ctx, accountID) + require.NoError(t, err, "read network routers") + + assert.Contains(t, got, "net-peer-nil", + "a router referencing an individual peer (peer_groups stored as NULL) must reach the network map") + assert.Contains(t, got["net-peer-nil"], "peer-direct-nil", + "the individual-peer router must be keyed by its peer") + + assert.Contains(t, got, "net-peer-empty", + "a router referencing an individual peer (peer_groups stored as '[]') must reach the network map") + assert.Contains(t, got["net-peer-empty"], "peer-direct-empty", + "the individual-peer router must be keyed by its peer") + + assert.Contains(t, got, "net-group", "a group router must reach the network map") + assert.Contains(t, got["net-group"], memberID, + "the group router must fan out to the group's member peers") +} diff --git a/management/internals/network_map_db/sqlite/network_router.go b/management/internals/network_map_db/sqlite/network_router.go index 8c4c31cd6..9fb0fde7e 100644 --- a/management/internals/network_map_db/sqlite/network_router.go +++ b/management/internals/network_map_db/sqlite/network_router.go @@ -11,9 +11,11 @@ import ( ) const ( + // Outer join: a groupless router must survive. GetNetworkRouterQuery = ` select public_id, peer, network_id, masquerade, metric, enabled, peer_groups, group_peers.peer_id - from network_routers, json_each(peer_groups) + from network_routers + left join json_each(network_routers.peer_groups) on true left join group_peers on group_peers.account_id=? and group_peers.group_id=json_each.value where network_routers.account_id=? ` From c455a4ac31041e40bd277cc9a6956d206e7cb890 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Fri, 4 Sep 2026 10:00:35 +0200 Subject: [PATCH 07/63] [management] disallow weird ip addresses for direct upstream hosts (#7400) * disallow weird ip addresses for direct upstream hosts Signed-off-by: Dmitri Dolguikh * handle bracketed ipv6 addresses Signed-off-by: Dmitri Dolguikh * extend the check to subnet service targets Signed-off-by: Dmitri Dolguikh * fix spelling Signed-off-by: Dmitri Dolguikh * reject ipv6 addresses with zones Signed-off-by: Dmitri Dolguikh * catch host:port hostnames in services with subnet targets Signed-off-by: Dmitri Dolguikh * make linter happy Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- .../modules/reverseproxy/service/service.go | 43 +++++++++++++- .../reverseproxy/service/service_test.go | 58 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go index 1488003cb..ef92fbbf4 100644 --- a/management/internals/modules/reverseproxy/service/service.go +++ b/management/internals/modules/reverseproxy/service/service.go @@ -55,6 +55,8 @@ const ( SourceEphemeral = "ephemeral" ) +var ErrUnsupportedIPAddressUpstreamHost = errors.New("unsupported ip address for a direct upstream host") + type TargetOptions struct { SkipTLSVerify bool `json:"skip_tls_verify"` RequestTimeout time.Duration `json:"request_timeout,omitempty"` @@ -962,8 +964,8 @@ func (s *Service) validateHTTPTargets() error { return err } case TargetTypeSubnet: - if target.Host == "" { - return fmt.Errorf("target %d has empty host but target_type is %q", i, target.TargetType) + if err := validateSubnetTarget(i, target); err != nil { + return err } case TargetTypeCluster: if err := validateClusterTarget(i, target); err != nil { @@ -986,6 +988,34 @@ func (s *Service) validateHTTPTargets() error { return nil } +func validateSubnetTarget(idx int, target *Target) error { + host := strings.TrimSpace(target.Host) + if host == "" { + return fmt.Errorf("target %d has empty host but target_type is %q", idx, target.TargetType) + } + if strings.ContainsAny(host, " \t/") { + return fmt.Errorf("target %d: host %q contains invalid characters", idx, host) + } + if _, _, err := net.SplitHostPort(host); err == nil { + return fmt.Errorf("target %d: host %q must not include a port (set target.port instead)", idx, host) + } + noBrackets := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") + maybeip, err := netip.ParseAddr(noBrackets) + if err != nil { // not an ip + return nil //nolint:nilerr + } + if maybeip.Zone() != "" { + return fmt.Errorf("invalid direct upstream host ip %s %w", maybeip.String(), ErrUnsupportedIPAddressUpstreamHost) + } + if !target.Options.DirectUpstream { + return nil + } + if maybeip.IsLoopback() || maybeip.IsMulticast() || maybeip.IsLinkLocalUnicast() { + return fmt.Errorf("invalid direct upstream host ip %s %w", maybeip.String(), ErrUnsupportedIPAddressUpstreamHost) + } + return nil +} + // validateClusterTarget cluster targets should not have empty hosts and should have direct upstream enabled. func validateClusterTarget(idx int, target *Target) error { host := strings.TrimSpace(target.Host) @@ -1020,6 +1050,15 @@ func validateDirectUpstreamHost(idx int, target *Target) error { if _, _, err := net.SplitHostPort(host); err == nil { return fmt.Errorf("target %d: host %q must not include a port (set target.port instead)", idx, host) } + noBrackets := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") + maybeip, err := netip.ParseAddr(noBrackets) + if err != nil { // not an ip + return nil //nolint:nilerr + } + if maybeip.Zone() != "" || maybeip.IsLoopback() || maybeip.IsMulticast() || maybeip.IsLinkLocalUnicast() { + return fmt.Errorf("invalid direct upstream host ip %s %w", maybeip.String(), ErrUnsupportedIPAddressUpstreamHost) + } + return nil } diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go index fc59968a2..84343bcdd 100644 --- a/management/internals/modules/reverseproxy/service/service_test.go +++ b/management/internals/modules/reverseproxy/service/service_test.go @@ -216,6 +216,64 @@ func TestValidateTargetOptions_CustomHeaders(t *testing.T) { }) } +func TestValidate_DirectUpstreamHost(t *testing.T) { + target := Target{TargetId: "id-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true, Options: TargetOptions{DirectUpstream: true}} + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "127.0.0.2")), ErrUnsupportedIPAddressUpstreamHost) + assert.NotNil(t, validateDirectUpstreamHost(0, targetWithHost(&target, "127.0.0.2:80")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "::1")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "::1%lo0")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1]")), ErrUnsupportedIPAddressUpstreamHost) + assert.NotNil(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1]:80")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1%lo0]")), ErrUnsupportedIPAddressUpstreamHost) + assert.NotNil(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1%lo0]:80")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "169.254.100.100")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "fe80::1")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[fe80::1]")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "224.100.100.100")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "ff00::ffff")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[ff00::ffff]")), ErrUnsupportedIPAddressUpstreamHost) + + // empty host + assert.Nil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: " "})) + // host with a space + assert.NotNil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with space"})) + // host with a tab + assert.NotNil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with\ttab"})) + // host with a slash + assert.NotNil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with/slash"})) +} + +func TestValidate_ValidateSubnetTarget(t *testing.T) { + target := Target{TargetId: "id-1", TargetType: TargetTypeSubnet, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true, Options: TargetOptions{DirectUpstream: true}} + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "127.0.0.2")), ErrUnsupportedIPAddressUpstreamHost) + assert.NotNil(t, validateSubnetTarget(0, targetWithHost(&target, "127.0.0.2:80")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "::1")), ErrUnsupportedIPAddressUpstreamHost) + assert.NotNil(t, validateSubnetTarget(0, targetWithHost(&target, "[::1]:80")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "::1%lo0")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "[::1%lo0]")), ErrUnsupportedIPAddressUpstreamHost) + assert.NotNil(t, validateSubnetTarget(0, targetWithHost(&target, "[::1%lo0]:80")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "169.254.100.100")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "fe80::1")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "[fe80::1]")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "224.100.100.100")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "ff00::ffff")), ErrUnsupportedIPAddressUpstreamHost) + assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "[ff00::ffff]")), ErrUnsupportedIPAddressUpstreamHost) + + // empty host + assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: " "})) + // host with a space + assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with space"})) + // host with a tab + assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with\ttab"})) + // host with a slash + assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with/slash"})) +} + +func targetWithHost(t *Target, host string) *Target { + t.Host = host + return t +} + func TestToProtoMapping_TargetOptions(t *testing.T) { rp := &Service{ ID: "svc-1", From 13ab50b9012d8618c6a684e82418df8e3c9cb298 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Fri, 4 Sep 2026 11:03:28 +0300 Subject: [PATCH 08/63] [management] Add SetNX and GetDel cache store operations (#7084) --- .../service/manager/manager_test.go | 6 +- management/internals/server/boot.go | 6 +- .../internals/shared/grpc/pkce_verifier.go | 19 +-- .../shared/grpc/pkce_verifier_test.go | 85 ++++++++++ .../internals/shared/grpc/proxy_test.go | 3 +- management/server/auth/session.go | 32 ++-- management/server/auth/session_test.go | 51 ++++++ management/server/cache/memory.go | 57 +++++++ management/server/cache/memory_test.go | 76 +++++++++ management/server/cache/redis.go | 63 ++++++++ management/server/cache/redis_test.go | 153 ++++++++++++++++++ management/server/cache/store.go | 47 ++---- management/server/cache/store_test.go | 126 +++++---------- 13 files changed, 565 insertions(+), 159 deletions(-) create mode 100644 management/internals/shared/grpc/pkce_verifier_test.go create mode 100644 management/server/cache/memory.go create mode 100644 management/server/cache/memory_test.go create mode 100644 management/server/cache/redis.go create mode 100644 management/server/cache/redis_test.go diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index 10893673e..dd0edec60 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -7,11 +7,10 @@ import ( "testing" "time" - cachestore "github.com/eko/gocache/lib/v4/store" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/metric/noop" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" @@ -31,7 +30,7 @@ import ( "github.com/netbirdio/netbird/shared/management/status" ) -func testCacheStore(t *testing.T) cachestore.StoreInterface { +func testCacheStore(t *testing.T) nbcache.Store { t.Helper() s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100) require.NoError(t, err) @@ -295,6 +294,7 @@ func TestPersistNewService(t *testing.T) { assert.Equal(t, status.AlreadyExists, sErr.Type()) }) } + func TestPreserveExistingAuthSecrets(t *testing.T) { mgr := &Manager{} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 0a4df3924..87a36a93b 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -21,8 +21,6 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/keepalive" - cachestore "github.com/eko/gocache/lib/v4/store" - "github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/formatter/hook" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" @@ -75,8 +73,8 @@ func (s *BaseServer) Metrics() telemetry.AppMetrics { // CacheStore returns a shared cache store backed by Redis or in-memory depending on the environment. // All consumers should reuse this store to avoid creating multiple Redis connections. -func (s *BaseServer) CacheStore() cachestore.StoreInterface { - return Create(s, func() cachestore.StoreInterface { +func (s *BaseServer) CacheStore() nbcache.Store { + return Create(s, func() nbcache.Store { cs, err := nbcache.NewStore(context.Background(), nbcache.DefaultStoreMaxTimeout, nbcache.DefaultStoreCleanupInterval, nbcache.DefaultStoreMaxConn) if err != nil { log.Fatalf("failed to create shared cache store: %v", err) diff --git a/management/internals/shared/grpc/pkce_verifier.go b/management/internals/shared/grpc/pkce_verifier.go index a1325256c..18155dc1d 100644 --- a/management/internals/shared/grpc/pkce_verifier.go +++ b/management/internals/shared/grpc/pkce_verifier.go @@ -5,22 +5,23 @@ import ( "fmt" "time" - "github.com/eko/gocache/lib/v4/cache" "github.com/eko/gocache/lib/v4/store" log "github.com/sirupsen/logrus" + + nbcache "github.com/netbirdio/netbird/management/server/cache" ) // PKCEVerifierStore manages PKCE verifiers for OAuth flows. // Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var. type PKCEVerifierStore struct { - cache *cache.Cache[string] + cache nbcache.Store ctx context.Context } // NewPKCEVerifierStore creates a PKCE verifier store using the provided shared cache store. -func NewPKCEVerifierStore(ctx context.Context, cacheStore store.StoreInterface) *PKCEVerifierStore { +func NewPKCEVerifierStore(ctx context.Context, cacheStore nbcache.Store) *PKCEVerifierStore { return &PKCEVerifierStore{ - cache: cache.New[string](cacheStore), + cache: cacheStore, ctx: ctx, } } @@ -40,14 +41,14 @@ func (s *PKCEVerifierStore) Store(state, verifier string, ttl time.Duration) err // Returns the verifier and true if found, or empty string and false if not found. // This enforces single-use semantics for PKCE verifiers. func (s *PKCEVerifierStore) LoadAndDelete(state string) (string, bool) { - verifier, err := s.cache.Get(s.ctx, state) + verifier, found, err := s.cache.GetDel(s.ctx, state) if err != nil { - log.Debugf("PKCE verifier not found for state") + log.Warnf("Failed to consume PKCE verifier: %v", err) return "", false } - - if err := s.cache.Delete(s.ctx, state); err != nil { - log.Warnf("Failed to delete PKCE verifier for state: %v", err) + if !found { + log.Debug("PKCE verifier not found for state") + return "", false } return verifier, true diff --git a/management/internals/shared/grpc/pkce_verifier_test.go b/management/internals/shared/grpc/pkce_verifier_test.go new file mode 100644 index 000000000..e7175b6c5 --- /dev/null +++ b/management/internals/shared/grpc/pkce_verifier_test.go @@ -0,0 +1,85 @@ +package grpc + +import ( + "context" + "testing" + "time" +) + +func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { + const ( + state = "state" + verifier = "verifier" + attempts = 64 + ) + + t.Run("exactly one concurrent caller consumes the verifier", func(t *testing.T) { + store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + if err := store.Store(state, verifier, time.Minute); err != nil { + t.Fatalf("couldn't store PKCE verifier: %s", err) + } + + start := make(chan struct{}) + type result struct { + verifier string + found bool + } + results := make(chan result, attempts) + for range attempts { + go func() { + <-start + verifier, found := store.LoadAndDelete(state) + results <- result{verifier: verifier, found: found} + }() + } + close(start) + + winners := 0 + for range attempts { + result := <-results + if result.found { + winners++ + if result.verifier != verifier { + t.Fatalf("unexpected verifier: got %q, expected %q", result.verifier, verifier) + } + } + } + if winners != 1 { + t.Fatalf("expected exactly one PKCE verifier consumer, got %d", winners) + } + }) + + t.Run("replayed state is rejected", func(t *testing.T) { + store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + if err := store.Store(state, verifier, time.Minute); err != nil { + t.Fatalf("couldn't store PKCE verifier: %s", err) + } + + if got, found := store.LoadAndDelete(state); !found || got != verifier { + t.Fatalf("first load should return the verifier, got %q, found %t", got, found) + } + if got, found := store.LoadAndDelete(state); found { + t.Fatalf("replayed state should not resolve, got %q", got) + } + }) + + t.Run("unknown state is rejected", func(t *testing.T) { + store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + + if got, found := store.LoadAndDelete("never-stored"); found { + t.Fatalf("unknown state should not resolve, got %q", got) + } + }) + + t.Run("expired verifier is rejected", func(t *testing.T) { + store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + if err := store.Store(state, verifier, 50*time.Millisecond); err != nil { + t.Fatalf("couldn't store PKCE verifier: %s", err) + } + + time.Sleep(100 * time.Millisecond) + if got, found := store.LoadAndDelete(state); found { + t.Fatalf("expired verifier should not resolve, got %q", got) + } + }) +} diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go index 0379edc6d..29b7c9523 100644 --- a/management/internals/shared/grpc/proxy_test.go +++ b/management/internals/shared/grpc/proxy_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - cachestore "github.com/eko/gocache/lib/v4/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -21,7 +20,7 @@ import ( "github.com/netbirdio/netbird/shared/management/proto" ) -func testCacheStore(t *testing.T) cachestore.StoreInterface { +func testCacheStore(t *testing.T) nbcache.Store { t.Helper() s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100) require.NoError(t, err) diff --git a/management/server/auth/session.go b/management/server/auth/session.go index 7621a1c10..778146589 100644 --- a/management/server/auth/session.go +++ b/management/server/auth/session.go @@ -7,9 +7,6 @@ import ( "errors" "fmt" "time" - - "github.com/eko/gocache/lib/v4/cache" - "github.com/eko/gocache/lib/v4/store" ) const ( @@ -22,12 +19,17 @@ var ( ErrTokenExpired = errors.New("JWT expired") ) -type SessionStore struct { - cache *cache.Cache[string] +// TokenCache atomically records used JWTs until their expiration. +type TokenCache interface { + SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error) } -func NewSessionStore(cacheStore store.StoreInterface) *SessionStore { - return &SessionStore{cache: cache.New[string](cacheStore)} +type SessionStore struct { + cache TokenCache +} + +func NewSessionStore(cacheStore TokenCache) *SessionStore { + return &SessionStore{cache: cacheStore} } // RegisterToken records a JWT until its exp time and rejects reuse. @@ -38,20 +40,14 @@ func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresA } key := usedTokenKeyPrefix + hashToken(token) - _, err := s.cache.Get(ctx, key) - if err == nil { + created, err := s.cache.SetNX(ctx, key, usedTokenMarker, ttl) + if err != nil { + return fmt.Errorf("store used token entry: %w", err) + } + if !created { return ErrTokenAlreadyUsed } - var notFound *store.NotFound - if !errors.As(err, ¬Found) { - return fmt.Errorf("failed to lookup used token entry: %w", err) - } - - if err := s.cache.Set(ctx, key, usedTokenMarker, store.WithExpiration(ttl)); err != nil { - return fmt.Errorf("failed to store used token entry: %w", err) - } - return nil } diff --git a/management/server/auth/session_test.go b/management/server/auth/session_test.go index 3a7d85f4c..7c82dfc43 100644 --- a/management/server/auth/session_test.go +++ b/management/server/auth/session_test.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "testing" "time" @@ -38,6 +39,39 @@ func TestSessionStore_RegisterSameTokenTwiceIsRejected(t *testing.T) { assert.ErrorIs(t, err, ErrTokenAlreadyUsed) } +func TestSessionStore_ConcurrentRegistrationAllowsOneCaller(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + const attempts = 100 + + start := make(chan struct{}) + results := make(chan error, attempts) + for range attempts { + go func() { + <-start + results <- s.RegisterToken(ctx, "token", time.Now().Add(time.Hour)) + }() + } + close(start) + + succeeded := 0 + alreadyUsed := 0 + for range attempts { + err := <-results + switch { + case err == nil: + succeeded++ + case errors.Is(err, ErrTokenAlreadyUsed): + alreadyUsed++ + default: + require.NoError(t, err, "concurrent registration returned an unexpected error") + } + } + + assert.Equal(t, 1, succeeded, "exactly one concurrent caller should register the token") + assert.Equal(t, attempts-1, alreadyUsed, "every other caller should be rejected as already used") +} + func TestSessionStore_RegisterDifferentTokensAreIndependent(t *testing.T) { s := newTestSessionStore(t) ctx := context.Background() @@ -72,6 +106,23 @@ func TestSessionStore_EntryEvictsAtTTLAndAllowsReRegistration(t *testing.T) { require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(time.Hour))) } +type failingTokenCache struct { + err error +} + +func (f failingTokenCache) SetNX(context.Context, string, string, time.Duration) (bool, error) { + return false, f.err +} + +func TestSessionStore_CacheErrorIsReturned(t *testing.T) { + cacheErr := errors.New("cache unavailable") + s := NewSessionStore(failingTokenCache{err: cacheErr}) + + err := s.RegisterToken(context.Background(), "token", time.Now().Add(time.Hour)) + require.Error(t, err, "cache failure should be surfaced to the caller") + assert.ErrorIs(t, err, cacheErr, "cache error should be wrapped, not replaced") +} + func TestHashToken_StableAndDoesNotLeak(t *testing.T) { a := hashToken("tokenA") b := hashToken("tokenB") diff --git a/management/server/cache/memory.go b/management/server/cache/memory.go new file mode 100644 index 000000000..f140f3ec8 --- /dev/null +++ b/management/server/cache/memory.go @@ -0,0 +1,57 @@ +package cache + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/eko/gocache/lib/v4/store" + gocachestore "github.com/eko/gocache/store/go_cache/v4" + gocache "github.com/patrickmn/go-cache" +) + +type goCacheStore struct { + store.StoreInterface + client *gocache.Cache + mu sync.Mutex +} + +func newMemoryStore(maxTimeout, cleanupInterval time.Duration) Store { + client := gocache.New(maxTimeout, cleanupInterval) + return &goCacheStore{ + StoreInterface: gocachestore.NewGoCache(client), + client: client, + } +} + +func (s *goCacheStore) SetNX(_ context.Context, key, value string, ttl time.Duration) (bool, error) { + // Add only returns an error when a non-expired entry already exists. + if err := s.client.Add(key, value, ttl); err != nil { + return false, nil //nolint:nilerr + } + return true, nil +} + +// GetDel reads the value under key and removes it. go-cache has no native read-and-delete +// and releases its own lock between the two calls, so mu holds the pair together and no +// value is consumed twice. +// +// Writes do not take mu: a Set landing mid-pair is lost, since GetDel returns the prior +// value and deletes the new one. Callers must write a consumed key only once. +func (s *goCacheStore) GetDel(_ context.Context, key string) (string, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + value, found := s.client.Get(key) + if !found { + return "", false, nil + } + s.client.Delete(key) + + str, ok := value.(string) + if !ok { + return "", false, fmt.Errorf("cached value is %T, not a string", value) + } + return str, true, nil +} diff --git a/management/server/cache/memory_test.go b/management/server/cache/memory_test.go new file mode 100644 index 000000000..363504921 --- /dev/null +++ b/management/server/cache/memory_test.go @@ -0,0 +1,76 @@ +package cache_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/cache" +) + +func TestMemoryStore(t *testing.T) { + memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + require.NoError(t, err, "couldn't create memory store") + + ctx := context.Background() + key, value := "testing", "tested" + err = memStore.Set(ctx, key, value) + assert.NoError(t, err, "couldn't set testing data") + + result, err := memStore.Get(ctx, key) + assert.NoError(t, err, "couldn't get testing data") + assert.Equal(t, value, result, "value returned doesn't match testing data") + + created, err := memStore.SetNX(ctx, "conditional", value, 100*time.Millisecond) + require.NoError(t, err, "couldn't conditionally set testing data") + require.True(t, created, "first conditional set should create the entry") + + created, err = memStore.SetNX(ctx, "conditional", value, 100*time.Millisecond) + require.NoError(t, err, "couldn't conditionally check testing data") + require.False(t, created, "second conditional set should not replace the entry") + + // test expiration + time.Sleep(300 * time.Millisecond) + _, err = memStore.Get(ctx, key) + assert.Error(t, err, "value should not be found") +} + +func TestMemoryStoreGetDel(t *testing.T) { + ctx := context.Background() + newStore := func(t *testing.T) cache.Store { + t.Helper() + memStore, err := cache.NewStore(ctx, time.Minute, time.Minute, 100) + require.NoError(t, err, "couldn't create memory store") + + return memStore + } + + const ( + key = "consume" + value = "verifier" + ) + + t.Run("exactly one concurrent caller consumes the key", func(t *testing.T) { + memStore := newStore(t) + require.NoError(t, memStore.Set(ctx, key, value), "couldn't set testing data") + + assertGetDelConsumedOnce(ctx, t, []cache.Store{memStore}, key, value) + assertGetDelMisses(ctx, t, memStore, key) + }) + + t.Run("missing key is not an error", func(t *testing.T) { + assertGetDelMisses(ctx, t, newStore(t), "never-set") + }) + + t.Run("expired key is not found", func(t *testing.T) { + memStore := newStore(t) + _, err := memStore.SetNX(ctx, key, value, 50*time.Millisecond) + require.NoError(t, err, "couldn't set testing data") + + time.Sleep(100 * time.Millisecond) + assertGetDelMisses(ctx, t, memStore, key) + }) +} diff --git a/management/server/cache/redis.go b/management/server/cache/redis.go new file mode 100644 index 000000000..0cd921c92 --- /dev/null +++ b/management/server/cache/redis.go @@ -0,0 +1,63 @@ +package cache + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + "github.com/eko/gocache/lib/v4/store" + redisstore "github.com/eko/gocache/store/redis/v4" + "github.com/redis/go-redis/v9" + log "github.com/sirupsen/logrus" +) + +type redisStore struct { + store.StoreInterface + client *redis.Client +} + +func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (Store, error) { + options, err := redis.ParseURL(redisEnvAddr) + if err != nil { + return nil, fmt.Errorf("parsing redis cache url: %s", err) + } + + options.MaxIdleConns = int(math.Ceil(float64(maxConn) * 0.5)) // 50% of max conns + options.MinIdleConns = int(math.Ceil(float64(maxConn) * 0.1)) // 10% of max conns + options.MaxActiveConns = maxConn + options.ConnMaxIdleTime = 30 * time.Minute + options.ConnMaxLifetime = 0 + options.PoolTimeout = 10 * time.Second + redisClient := redis.NewClient(options) + subCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + _, err = redisClient.Ping(subCtx).Result() + if err != nil { + return nil, err + } + + log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr) + + return &redisStore{ + StoreInterface: redisstore.NewRedis(redisClient), + client: redisClient, + }, nil +} + +func (s *redisStore) SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error) { + return s.client.SetNX(ctx, key, value, ttl).Result() +} + +func (s *redisStore) GetDel(ctx context.Context, key string) (string, bool, error) { + value, err := s.client.GetDel(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return value, true, nil +} diff --git a/management/server/cache/redis_test.go b/management/server/cache/redis_test.go new file mode 100644 index 000000000..994ec7490 --- /dev/null +++ b/management/server/cache/redis_test.go @@ -0,0 +1,153 @@ +package cache_test + +import ( + "context" + "testing" + "time" + + "github.com/eko/gocache/lib/v4/store" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis" + + "github.com/netbirdio/netbird/management/server/cache" +) + +func startRedis(t *testing.T) string { + t.Helper() + + ctx := context.Background() + redisContainer, err := testcontainersredis.Run(ctx, "redis:7") + require.NoError(t, err, "couldn't start redis container") + + t.Cleanup(func() { + if err := redisContainer.Terminate(ctx); err != nil { + t.Logf("failed to terminate container: %s", err) + } + }) + + redisURL, err := redisContainer.ConnectionString(ctx) + require.NoError(t, err, "couldn't get connection string") + + t.Setenv(cache.RedisStoreEnvVar, redisURL) + return redisURL +} + +func newRedisStore(t *testing.T) cache.Store { + t.Helper() + + redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + require.NoError(t, err) + + return redisStore +} + +func TestRedisStoreConnectionFailure(t *testing.T) { + t.Setenv(cache.RedisStoreEnvVar, "redis://127.0.0.1:6379") + _, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond, 100) + require.Error(t, err, "getting redis cache store should return error") +} + +func TestRedisStoreConnectionSuccess(t *testing.T) { + ctx := context.Background() + redisURL := startRedis(t) + redisStore := newRedisStore(t) + + key, value := "testing", "tested" + err := redisStore.Set(ctx, key, value, store.WithExpiration(100*time.Millisecond)) + assert.NoError(t, err, "couldn't set testing data") + + result, err := redisStore.Get(ctx, key) + assert.NoError(t, err, "couldn't get testing data") + assert.Equal(t, value, result, "value returned doesn't match testing data") + + options, err := redis.ParseURL(redisURL) + require.NoError(t, err, "parsing redis cache url") + + redisClient := redis.NewClient(options) + r, err := redisClient.Get(ctx, key).Result() + assert.NoError(t, err, "couldn't get testing data from redis") + assert.Equal(t, value, r, "value returned from redis doesn't match testing data") + + // test expiration + time.Sleep(300 * time.Millisecond) + _, err = redisStore.Get(ctx, key) + assert.Error(t, err, "value should not be found") +} + +func TestRedisStoreSetNX(t *testing.T) { + ctx := context.Background() + redisURL := startRedis(t) + redisStore, secondRedisStore := newRedisStore(t), newRedisStore(t) + + const ( + key = "conditional" + value = "tested" + ) + + start := make(chan struct{}) + type setResult struct { + created bool + err error + } + results := make(chan setResult, 2) + for _, cacheStore := range []cache.Store{redisStore, secondRedisStore} { + go func() { + <-start + created, err := cacheStore.SetNX(ctx, key, value, time.Minute) + results <- setResult{created: created, err: err} + }() + } + close(start) + + created := 0 + for range 2 { + result := <-results + require.NoError(t, result.err, "conditional redis set failed") + if result.created { + created++ + } + } + require.Equal(t, 1, created, "expected exactly one redis client to create the entry") + + options, err := redis.ParseURL(redisURL) + require.NoError(t, err, "parsing redis cache url") + + ttl, err := redis.NewClient(options).PTTL(ctx, key).Result() + require.NoError(t, err, "couldn't read entry TTL") + require.Positive(t, ttl, "created entry should have a positive TTL") +} + +func TestRedisStoreGetDel(t *testing.T) { + ctx := context.Background() + startRedis(t) + redisStore, secondRedisStore := newRedisStore(t), newRedisStore(t) + + const ( + key = "consume" + value = "verifier" + ) + + t.Run("exactly one caller across independent clients consumes the key", func(t *testing.T) { + // A generous TTL: the key is consumed explicitly, so expiry racing the + // concurrent callers would only make the test flaky on a loaded runner. + err := redisStore.Set(ctx, key, value, store.WithExpiration(time.Minute)) + require.NoError(t, err, "couldn't set value to consume") + + assertGetDelConsumedOnce(ctx, t, []cache.Store{redisStore, secondRedisStore}, key, value) + assertGetDelMisses(ctx, t, secondRedisStore, key) + }) + + t.Run("missing key is not an error", func(t *testing.T) { + assertGetDelMisses(ctx, t, redisStore, "never-set") + }) + + t.Run("expired key is not found", func(t *testing.T) { + err := redisStore.Set(ctx, key, value, store.WithExpiration(50*time.Millisecond)) + require.NoError(t, err, "couldn't set value to consume") + + time.Sleep(100 * time.Millisecond) + assertGetDelMisses(ctx, t, redisStore, key) + }) +} diff --git a/management/server/cache/store.go b/management/server/cache/store.go index 2ca8e8603..a0c093e5d 100644 --- a/management/server/cache/store.go +++ b/management/server/cache/store.go @@ -2,17 +2,10 @@ package cache import ( "context" - "fmt" - "math" "os" "time" "github.com/eko/gocache/lib/v4/store" - gocache_store "github.com/eko/gocache/store/go_cache/v4" - redis_store "github.com/eko/gocache/store/redis/v4" - gocache "github.com/patrickmn/go-cache" - "github.com/redis/go-redis/v9" - log "github.com/sirupsen/logrus" ) // RedisStoreEnvVar is the environment variable that determines if a redis store should be used. @@ -31,15 +24,23 @@ const ( DefaultStoreMaxConn = 1000 ) +// Store extends the shared cache interface with conditional and consuming operations. +type Store interface { + store.StoreInterface + // SetNX stores a value with a TTL only when the key does not exist. + SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error) + // GetDel reads a value and removes it, so only one caller can consume a key. + GetDel(ctx context.Context, key string) (value string, found bool, err error) +} + // NewStore creates a new cache store with the given max timeout and cleanup interval. It checks for the environment Variable RedisStoreEnvVar // to determine if a redis store should be used. If the environment variable is set, it will attempt to connect to the redis store. -func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (store.StoreInterface, error) { +func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (Store, error) { redisAddr := GetAddrFromEnv() if redisAddr != "" { return getRedisStore(ctx, redisAddr, maxConn) } - goc := gocache.New(maxTimeout, cleanupInterval) - return gocache_store.NewGoCache(goc), nil + return newMemoryStore(maxTimeout, cleanupInterval), nil } // GetAddrFromEnv returns the redis address from the environment variable RedisStoreEnvVar or its legacy counterpart. @@ -50,29 +51,3 @@ func GetAddrFromEnv() string { } return addr } - -func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (store.StoreInterface, error) { - options, err := redis.ParseURL(redisEnvAddr) - if err != nil { - return nil, fmt.Errorf("parsing redis cache url: %s", err) - } - - options.MaxIdleConns = int(math.Ceil(float64(maxConn) * 0.5)) // 50% of max conns - options.MinIdleConns = int(math.Ceil(float64(maxConn) * 0.1)) // 10% of max conns - options.MaxActiveConns = maxConn - options.ConnMaxIdleTime = 30 * time.Minute - options.ConnMaxLifetime = 0 - options.PoolTimeout = 10 * time.Second - redisClient := redis.NewClient(options) - subCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - - _, err = redisClient.Ping(subCtx).Result() - if err != nil { - return nil, err - } - - log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr) - - return redis_store.NewRedis(redisClient), nil -} diff --git a/management/server/cache/store_test.go b/management/server/cache/store_test.go index b869170f0..a59be8393 100644 --- a/management/server/cache/store_test.go +++ b/management/server/cache/store_test.go @@ -3,101 +3,53 @@ package cache_test import ( "context" "testing" - "time" - "github.com/eko/gocache/lib/v4/store" - "github.com/redis/go-redis/v9" - testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis" + "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/server/cache" ) -func TestMemoryStore(t *testing.T) { - memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) - if err != nil { - t.Fatalf("couldn't create memory store: %s", err) - } - ctx := context.Background() - key, value := "testing", "tested" - err = memStore.Set(ctx, key, value) - if err != nil { - t.Errorf("couldn't set testing data: %s", err) - } - result, err := memStore.Get(ctx, key) - if err != nil { - t.Errorf("couldn't get testing data: %s", err) - } - if value != result.(string) { - t.Errorf("value returned doesn't match testing data, got %s, expected %s", result, value) - } - // test expiration - time.Sleep(300 * time.Millisecond) - _, err = memStore.Get(ctx, key) - if err == nil { - t.Error("value should not be found") - } -} +func assertGetDelConsumedOnce(ctx context.Context, t *testing.T, stores []cache.Store, key, value string) { + t.Helper() -func TestRedisStoreConnectionFailure(t *testing.T) { - t.Setenv(cache.RedisStoreEnvVar, "redis://127.0.0.1:6379") - _, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond, 100) - if err == nil { - t.Fatal("getting redis cache store should return error") - } -} + const getDelAttempts = 64 -func TestRedisStoreConnectionSuccess(t *testing.T) { - ctx := context.Background() - redisContainer, err := testcontainersredis.Run(ctx, "redis:7") - if err != nil { - t.Fatalf("couldn't start redis container: %s", err) + type getDelResult struct { + value string + found bool + err error } - defer func() { - if err := redisContainer.Terminate(ctx); err != nil { - t.Logf("failed to terminate container: %s", err) + + start := make(chan struct{}) + results := make(chan getDelResult, getDelAttempts) + for i := range getDelAttempts { + cacheStore := stores[i%len(stores)] + go func() { + <-start + value, found, err := cacheStore.GetDel(ctx, key) + results <- getDelResult{value: value, found: found, err: err} + }() + } + close(start) + + consumers := 0 + for range getDelAttempts { + result := <-results + require.NoError(t, result.err, "concurrent GetDel failed") + if !result.found { + continue } - }() - redisURL, err := redisContainer.ConnectionString(ctx) - if err != nil { - t.Fatalf("couldn't get connection string: %s", err) - } - - t.Setenv(cache.RedisStoreEnvVar, redisURL) - redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) - if err != nil { - t.Fatalf("couldn't create redis store: %s", err) - } - - key, value := "testing", "tested" - err = redisStore.Set(ctx, key, value, store.WithExpiration(100*time.Millisecond)) - if err != nil { - t.Errorf("couldn't set testing data: %s", err) - } - result, err := redisStore.Get(ctx, key) - if err != nil { - t.Errorf("couldn't get testing data: %s", err) - } - if value != result.(string) { - t.Errorf("value returned doesn't match testing data, got %s, expected %s", result, value) - } - - options, err := redis.ParseURL(redisURL) - if err != nil { - t.Errorf("parsing redis cache url: %s", err) - } - - redisClient := redis.NewClient(options) - r, e := redisClient.Get(ctx, key).Result() - if e != nil { - t.Errorf("couldn't get testing data from redis: %s", e) - } - if value != r { - t.Errorf("value returned from redis doesn't match testing data, got %s, expected %s", r, value) - } - // test expiration - time.Sleep(300 * time.Millisecond) - _, err = redisStore.Get(ctx, key) - if err == nil { - t.Error("value should not be found") + consumers++ + require.Equal(t, value, result.value, "consumed value doesn't match testing data") } + require.Equal(t, 1, consumers, "expected exactly one consumer") +} + +func assertGetDelMisses(ctx context.Context, t *testing.T, cacheStore cache.Store, key string) { + t.Helper() + + value, found, err := cacheStore.GetDel(ctx, key) + require.NoError(t, err, "GetDel on a missing key should not error") + require.False(t, found, "GetDel should not find key %q, got value %q", key, value) + require.Empty(t, value, "GetDel should return an empty value when not found") } From 066af82c3e4dee2cbec4e2e5855935b12321a281 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Fri, 4 Sep 2026 11:03:54 +0300 Subject: [PATCH 09/63] [management] Keep embedded IdP deployments on a single account (#7380) --- combined/cmd/root.go | 5 +- management/cmd/management.go | 3 + management/cmd/management_test.go | 22 +- management/server/account.go | 17 +- management/server/identity_provider_test.go | 40 ++- management/server/idp/migration/migration.go | 168 +++++++++++- .../server/idp/migration/migration_test.go | 249 ++++++++++++++++++ management/server/idp/migration/store.go | 14 + tools/idp-migrate/DEVELOPMENT.md | 2 + tools/idp-migrate/config.go | 21 +- tools/idp-migrate/main.go | 27 +- tools/idp-migrate/main_test.go | 74 ++++++ 12 files changed, 623 insertions(+), 19 deletions(-) diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 7eac84ce5..3e583ef20 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -365,7 +365,6 @@ func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) { }) } } - } func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, metricsServer *sharedMetrics.Metrics) { @@ -539,7 +538,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m &mgmtServer.Config{ NbConfig: mgmtConfig, DNSDomain: "", - MgmtSingleAccModeDomain: "", + MgmtSingleAccModeDomain: mgmtServer.DefaultSelfHostedDomain, AutoResolveDomains: true, MgmtPort: mgmtPort, MgmtMetricsPort: cfg.Server.MetricsPort, @@ -554,7 +553,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m } // createCombinedHandler creates an HTTP handler that multiplexes Management, Signal (via wsproxy), and Relay WebSocket traffic -func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, idpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler { +func createCombinedHandler(grpcServer *grpc.Server, httpHandler, idpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler { wsProxy := wsproxyserver.New(grpcServer, wsproxyserver.WithOTelMeter(meter)) var relayAcceptFn func(conn listener.Conn) diff --git a/management/cmd/management.go b/management/cmd/management.go index 147985314..fc6bd0a46 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -236,6 +236,9 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { // Embedded IdP requires single account mode - multiple account mode is not supported return fmt.Errorf("embedded IdP requires single account mode; multiple account mode is not supported with embedded IdP. Please remove --disable-single-account-mode flag") } + if mgmtSingleAccModeDomain == "" { + return fmt.Errorf("embedded IdP requires single account mode; --single-account-mode-domain must not be empty") + } // Enable user deletion from IDP by default if EmbeddedIdP is enabled userDeleteFromIDPEnabled = true diff --git a/management/cmd/management_test.go b/management/cmd/management_test.go index 2c3481213..e34e1975e 100644 --- a/management/cmd/management_test.go +++ b/management/cmd/management_test.go @@ -5,8 +5,12 @@ import ( "os" "testing" - "github.com/netbirdio/netbird/shared/management/grpc" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/shared/management/grpc" ) const ( @@ -60,6 +64,22 @@ func Test_LoadMgmtConfig_Empty(t *testing.T) { assert.Nil(t, cfg.PerAccountHighestSupportedSyncMessageVersion) } +func TestApplyEmbeddedIdPConfigRequiresSingleAccountDomain(t *testing.T) { + previousDomain := mgmtSingleAccModeDomain + previousDisabled := disableSingleAccMode + t.Cleanup(func() { + mgmtSingleAccModeDomain = previousDomain + disableSingleAccMode = previousDisabled + }) + + mgmtSingleAccModeDomain = "" + disableSingleAccMode = false + cfg := &nbconfig.Config{ + EmbeddedIdP: &idp.EmbeddedIdPConfig{Enabled: true}, + } + require.ErrorContains(t, ApplyEmbeddedIdPConfig(context.Background(), cfg), "embedded IdP requires single account mode") +} + func createConfig(config string) (string, error) { tmpfile, err := os.CreateTemp("", "config.json") if err != nil { diff --git a/management/server/account.go b/management/server/account.go index 58698e899..3ceef79db 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -14,10 +14,6 @@ import ( "sync" "time" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" - "github.com/netbirdio/netbird/management/server/job" - "github.com/netbirdio/netbird/shared/auth" - cacheStore "github.com/eko/gocache/lib/v4/store" "github.com/eko/gocache/store/redis/v4" "github.com/rs/xid" @@ -29,6 +25,7 @@ import ( "github.com/netbirdio/netbird/formatter/hook" "github.com/netbirdio/netbird/idp/dex" "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" @@ -39,6 +36,7 @@ import ( "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" @@ -50,6 +48,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/auth" nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/status" @@ -238,6 +237,10 @@ func BuildManager( log.WithContext(ctx).Error(err) } + if IsEmbeddedIdp(idpManager) && accountsCounter > 1 { + log.WithContext(ctx).Warnf("embedded IdP requires a single account, found %d", accountsCounter) + } + // enable single account mode only if configured by user and number of existing accounts is not grater than 1 am.singleAccountMode = singleAccountModeDomain != "" && accountsCounter <= 1 if am.singleAccountMode { @@ -1592,7 +1595,10 @@ func (am *DefaultAccountManager) updateUserAuthWithSingleMode(ctx context.Contex if err != nil { return err } - userAuth.Domain = domain + // Keep the configured single account domain when the existing account has none + if domain != "" { + userAuth.Domain = domain + } log.WithContext(ctx).Debugf("overriding JWT Domain and DomainCategory claims since single account mode is enabled") return nil @@ -1837,6 +1843,7 @@ func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context return am.addNewPrivateAccount(ctx, domainAccountID, userAuth) } + func (am *DefaultAccountManager) getPrivateDomainWithGlobalLock(ctx context.Context, domain string) (string, context.CancelFunc, error) { domainAccountID, err := am.Store.GetAccountIDByPrivateDomain(ctx, store.LockingStrengthNone, domain) if handleNotFound(err) != nil { diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go index eef69dc14..ecc47337c 100644 --- a/management/server/identity_provider_test.go +++ b/management/server/identity_provider_test.go @@ -10,9 +10,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/controllers/network_map/controller" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" @@ -34,6 +34,20 @@ import ( func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) { t.Helper() + return createManagerWithEmbeddedIdPMode(t, "netbird.selfhosted") +} + +func createManagerWithEmbeddedIdPMode(t testing.TB, singleAccountModeDomain string) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) { + t.Helper() + return createManagerWithEmbeddedIdPModeAndSetup(t, singleAccountModeDomain, nil) +} + +func createManagerWithEmbeddedIdPModeAndSetup( + t testing.TB, + singleAccountModeDomain string, + setupStore func(context.Context, store.Store) error, +) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) { + t.Helper() ctx := context.Background() @@ -43,6 +57,11 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update return nil, nil, err } t.Cleanup(cleanUp) + if setupStore != nil { + if err := setupStore(ctx, testStore); err != nil { + return nil, nil, err + } + } // Create embedded IdP manager embeddedConfig := &idp.EmbeddedIdPConfig{ @@ -93,7 +112,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{}, 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) + manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } @@ -196,6 +215,23 @@ func TestDefaultAccountManager_GetIdentityProvider_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "not found") } +func TestUpdateUserAuthWithSingleModeKeepsConfiguredDomain(t *testing.T) { + ctx := context.Background() + manager, _, err := createManagerWithEmbeddedIdPModeAndSetup(t, "netbird.selfhosted", func(ctx context.Context, testStore store.Store) error { + // An account with no domain, as left behind by an IdP that emitted no domain claims. + return testStore.SaveAccount(ctx, newAccountWithId(ctx, "account-1", "user-1", "", "", "", false)) + }) + require.NoError(t, err) + require.True(t, manager.singleAccountMode) + + userAuth := auth.UserAuth{UserId: "user-2"} + require.NoError(t, manager.updateUserAuthWithSingleMode(ctx, &userAuth)) + + assert.Equal(t, "netbird.selfhosted", userAuth.Domain, + "An empty account domain must not clear the configured single account domain") + assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory) +} + func TestDefaultAccountManager_UpdateIdentityProvider_Validation(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err) diff --git a/management/server/idp/migration/migration.go b/management/server/idp/migration/migration.go index 01cadb86d..bec0de84c 100644 --- a/management/server/idp/migration/migration.go +++ b/management/server/idp/migration/migration.go @@ -10,6 +10,8 @@ import ( "errors" "fmt" "os" + "regexp" + "strings" log "github.com/sirupsen/logrus" @@ -25,8 +27,10 @@ type Server interface { EventStore() EventStore // may return nil } -const idpSeedInfoKey = "IDP_SEED_INFO" -const dryRunEnvKey = "NB_IDP_MIGRATION_DRY_RUN" +const ( + idpSeedInfoKey = "IDP_SEED_INFO" + dryRunEnvKey = "NB_IDP_MIGRATION_DRY_RUN" +) func isDryRun() bool { return os.Getenv(dryRunEnvKey) == "true" @@ -233,3 +237,163 @@ func PopulateUserInfo(s Server, idpManager idp.Manager, dryRun bool) error { return nil } + +const DefaultSingleAccountDomain = "netbird.selfhosted" + +var ( + ErrMultipleAccounts = errors.New("the embedded IdP supports a single account only") + ErrUnusableDomain = errors.New("domain cannot be resolved in single account mode") + ErrDomainConflict = errors.New("requested domain conflicts with the account domain") +) + +var resolvableDomainRegexp = regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`) + +// RequireSingleAccount refuses to migrate an instance that holds more than one account. +func RequireSingleAccount(s Server) error { + accountsCounter, err := s.Store().GetAccountsCounter(context.Background()) + if err != nil { + return fmt.Errorf("failed to count accounts: %w", err) + } + + if accountsCounter > 1 { + return errMultipleAccounts(accountsCounter) + } + + return nil +} + +func errMultipleAccounts(accountsCounter int64) error { + return fmt.Errorf("%w: this instance has %d accounts. Identity provider connectors are stored without "+ + "an account scope, so every account would share and be able to manage the same connectors. "+ + "Consolidate this instance to a single account, or keep using an external IdP, before migrating", + ErrMultipleAccounts, accountsCounter) +} + +func NormalizeSingleAccountDomain(singleAccountDomain string) (string, error) { + if singleAccountDomain == "" { + singleAccountDomain = DefaultSingleAccountDomain + } + + singleAccountDomain = strings.ToLower(singleAccountDomain) + if !resolvableDomainRegexp.MatchString(singleAccountDomain) { + return "", fmt.Errorf("%w: %q must contain at least one dot and only lowercase letters, digits and "+ + "hyphens, otherwise users cannot join the existing account", ErrUnusableDomain, singleAccountDomain) + } + + return singleAccountDomain, nil +} + +// resolveAccountDomain picks the domain the account should end up with. The account keeps a usable +// domain of its own, the configured one only fills a blank. Anything else is a conflict to report. +func resolveAccountDomain(accountID, accountDomain, singleAccountDomain string, requested bool) (string, error) { + accountDomain = strings.ToLower(accountDomain) + + if accountDomain == "" { + return singleAccountDomain, nil + } + + if !resolvableDomainRegexp.MatchString(accountDomain) { + return "", fmt.Errorf("%w: account %s has domain %q, which must contain at least one dot and only "+ + "lowercase letters, digits and hyphens. Correct the account domain before migrating", + ErrUnusableDomain, accountID, accountDomain) + } + + if requested && accountDomain != singleAccountDomain { + return "", fmt.Errorf("%w: account %s already uses domain %q but %q was requested. Re-run without "+ + "--single-account-mode-domain to keep %q, or correct the account domain first", + ErrDomainConflict, accountID, accountDomain, singleAccountDomain, accountDomain) + } + + return accountDomain, nil +} + +// EnsureSingleAccountDomain gives the remaining account the domain attributes single account mode +// resolves against, so users can still join it after the migration. +func EnsureSingleAccountDomain(s Server, singleAccountDomain string) error { + plan, err := planSingleAccountDomain(s, singleAccountDomain) + if err != nil { + return err + } + if plan.skip { + return nil + } + + if isDryRun() { + log.Infof("[DRY RUN] would set account %s domain to %q, category to %q and mark it as the primary domain account "+ + "(currently domain=%q primary=%v)", plan.accountID, plan.domain, types.PrivateCategory, + plan.currentDomain, plan.isPrimary) + return nil + } + + if err := s.Store().UpdateAccountDomainAttributes(context.Background(), plan.accountID, plan.domain, + types.PrivateCategory, true); err != nil { + return fmt.Errorf("failed to update domain attributes of account %s: %w", plan.accountID, err) + } + + log.Infof("account %s now resolves in single account mode with domain %q", plan.accountID, plan.domain) + return nil +} + +// CheckSingleAccountDomain reports whether EnsureSingleAccountDomain would succeed, without writing. +func CheckSingleAccountDomain(s Server, singleAccountDomain string) error { + _, err := planSingleAccountDomain(s, singleAccountDomain) + return err +} + +type singleAccountDomainPlan struct { + accountID string + domain string + currentDomain string + isPrimary bool + skip bool +} + +// planSingleAccountDomain decides what the account's domain attributes should become. It reads +// only, so it can run both as a preflight and as the first half of the update. +func planSingleAccountDomain(s Server, singleAccountDomain string) (singleAccountDomainPlan, error) { + ctx := context.Background() + + // An empty value means the operator did not pick a domain, so the default is only a fallback. + requested := singleAccountDomain != "" + + singleAccountDomain, err := NormalizeSingleAccountDomain(singleAccountDomain) + if err != nil { + return singleAccountDomainPlan{}, err + } + + accountsCounter, err := s.Store().GetAccountsCounter(ctx) + if err != nil { + return singleAccountDomainPlan{}, fmt.Errorf("failed to count accounts: %w", err) + } + // The count is checked again here: it is read long after RequireSingleAccount, and marking an + // arbitrary account as the primary one for the domain would be wrong. + switch { + case accountsCounter == 0: + log.Info("no accounts yet, nothing to prepare for single account mode") + return singleAccountDomainPlan{skip: true}, nil + case accountsCounter > 1: + return singleAccountDomainPlan{}, errMultipleAccounts(accountsCounter) + } + + accountID, err := s.Store().GetAnyAccountID(ctx) + if err != nil { + return singleAccountDomainPlan{}, fmt.Errorf("failed to get the existing account: %w", err) + } + + isPrimary, accountDomain, err := s.Store().IsPrimaryAccount(ctx, accountID) + if err != nil { + return singleAccountDomainPlan{}, fmt.Errorf("failed to read domain attributes of account %s: %w", accountID, err) + } + + domain, err := resolveAccountDomain(accountID, accountDomain, singleAccountDomain, requested) + if err != nil { + return singleAccountDomainPlan{}, err + } + + return singleAccountDomainPlan{ + accountID: accountID, + domain: domain, + currentDomain: accountDomain, + isPrimary: isPrimary, + }, nil +} diff --git a/management/server/idp/migration/migration_test.go b/management/server/idp/migration/migration_test.go index 2ff71347e..f6a436015 100644 --- a/management/server/idp/migration/migration_test.go +++ b/management/server/idp/migration/migration_test.go @@ -24,6 +24,17 @@ type testStore struct { checkSchemaFunc func(checks []SchemaCheck) []SchemaError updateCalls []updateUserIDCall updateInfoCalls []updateUserInfoCall + + accountsCounter int64 + accounts map[string]*types.Account + domainAttrCalls []domainAttrCall +} + +type domainAttrCall struct { + AccountID string + Domain string + Category string + IsPrimary bool } type updateUserIDCall struct { @@ -38,6 +49,35 @@ type updateUserInfoCall struct { Name string } +func (s *testStore) GetAccountsCounter(context.Context) (int64, error) { + return s.accountsCounter, nil +} + +func (s *testStore) GetAnyAccountID(context.Context) (string, error) { + for id := range s.accounts { + return id, nil + } + return "", fmt.Errorf("no accounts") +} + +func (s *testStore) IsPrimaryAccount(_ context.Context, accountID string) (bool, string, error) { + account, ok := s.accounts[accountID] + if !ok { + return false, "", fmt.Errorf("account %s not found", accountID) + } + return account.IsDomainPrimaryAccount, account.Domain, nil +} + +func (s *testStore) UpdateAccountDomainAttributes(_ context.Context, accountID, domain, category string, isPrimaryDomain bool) error { + s.domainAttrCalls = append(s.domainAttrCalls, domainAttrCall{accountID, domain, category, isPrimaryDomain}) + if account, ok := s.accounts[accountID]; ok { + account.Domain = domain + account.DomainCategory = category + account.IsDomainPrimaryAccount = isPrimaryDomain + } + return nil +} + func (s *testStore) ListUsers(ctx context.Context) ([]*types.User, error) { return s.listUsersFunc(ctx) } @@ -826,3 +866,212 @@ func TestCheckSchema_MockStore(t *testing.T) { assert.Equal(t, "email", errs[0].Column) }) } + +func TestRequireSingleAccount(t *testing.T) { + tests := []struct { + name string + accounts int64 + expectErr bool + }{ + {name: "fresh install", accounts: 0}, + {name: "single account", accounts: 1}, + {name: "multiple accounts", accounts: 3, expectErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := &testServer{store: &testStore{accountsCounter: tt.accounts}} + + err := RequireSingleAccount(srv) + if !tt.expectErr { + require.NoError(t, err) + return + } + + require.Error(t, err) + assert.ErrorIs(t, err, ErrMultipleAccounts) + }) + } +} + +func TestEnsureSingleAccountDomain(t *testing.T) { + tests := []struct { + name string + account *types.Account + requestedDomain string + expectedDomain string + }{ + { + name: "account migrated from an IdP without domain claims", + account: &types.Account{Id: "account-1"}, + expectedDomain: DefaultSingleAccountDomain, + }, + { + name: "requested domain is applied to an account without one", + account: &types.Account{Id: "account-1"}, + requestedDomain: "corp.example.com", + expectedDomain: "corp.example.com", + }, + { + name: "account keeps its own domain", + account: &types.Account{Id: "account-1", Domain: "acme.com"}, + expectedDomain: "acme.com", + }, + { + name: "requesting the domain the account already has is not a conflict", + account: &types.Account{Id: "account-1", Domain: "acme.com"}, + requestedDomain: "acme.com", + expectedDomain: "acme.com", + }, + { + name: "already resolvable account is rewritten with the same values", + account: &types.Account{ + Id: "account-1", + Domain: "acme.com", + DomainCategory: types.PrivateCategory, + IsDomainPrimaryAccount: true, + }, + expectedDomain: "acme.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &testStore{ + accountsCounter: 1, + accounts: map[string]*types.Account{tt.account.Id: tt.account}, + } + + require.NoError(t, EnsureSingleAccountDomain(&testServer{store: store}, tt.requestedDomain)) + + require.Len(t, store.domainAttrCalls, 1) + assert.Equal(t, domainAttrCall{ + AccountID: tt.account.Id, + Domain: tt.expectedDomain, + Category: types.PrivateCategory, + IsPrimary: true, + }, store.domainAttrCalls[0]) + }) + } +} + +func TestEnsureSingleAccountDomainDryRun(t *testing.T) { + t.Setenv(dryRunEnvKey, "true") + + store := &testStore{ + accountsCounter: 1, + accounts: map[string]*types.Account{"account-1": {Id: "account-1"}}, + } + + require.NoError(t, EnsureSingleAccountDomain(&testServer{store: store}, "")) + assert.Empty(t, store.domainAttrCalls, "Dry run must not write anything") +} + +func TestEnsureSingleAccountDomainRejectsUnresolvableDomains(t *testing.T) { + t.Run("account domain that cannot resolve is reported", func(t *testing.T) { + account := &types.Account{Id: "account-1", Domain: "corp"} + store := &testStore{ + accountsCounter: 1, + accounts: map[string]*types.Account{account.Id: account}, + } + + err := EnsureSingleAccountDomain(&testServer{store: store}, "") + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnusableDomain) + assert.Empty(t, store.domainAttrCalls, "A broken account domain must not be replaced silently") + }) + + t.Run("requested domain conflicting with the account domain is reported", func(t *testing.T) { + account := &types.Account{Id: "account-1", Domain: "acme.com"} + store := &testStore{ + accountsCounter: 1, + accounts: map[string]*types.Account{account.Id: account}, + } + + err := EnsureSingleAccountDomain(&testServer{store: store}, "corp.example.com") + require.Error(t, err) + assert.ErrorIs(t, err, ErrDomainConflict) + assert.Empty(t, store.domainAttrCalls, "A conflict must not overwrite the account domain") + }) + + t.Run("configured domain that cannot resolve is rejected", func(t *testing.T) { + store := &testStore{ + accountsCounter: 1, + accounts: map[string]*types.Account{"account-1": {Id: "account-1"}}, + } + + err := EnsureSingleAccountDomain(&testServer{store: store}, "corp") + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnusableDomain) + assert.Empty(t, store.domainAttrCalls) + }) + + t.Run("account appearing after the preflight is rejected", func(t *testing.T) { + store := &testStore{ + accountsCounter: 2, + accounts: map[string]*types.Account{ + "account-1": {Id: "account-1"}, + "account-2": {Id: "account-2"}, + }, + } + + err := EnsureSingleAccountDomain(&testServer{store: store}, "") + require.Error(t, err) + assert.ErrorIs(t, err, ErrMultipleAccounts) + assert.Empty(t, store.domainAttrCalls, "No account may be marked primary when several exist") + }) + + t.Run("fresh install with no accounts is a no-op", func(t *testing.T) { + store := &testStore{accountsCounter: 0, accounts: map[string]*types.Account{}} + + require.NoError(t, EnsureSingleAccountDomain(&testServer{store: store}, "")) + assert.Empty(t, store.domainAttrCalls) + }) +} + +func TestCheckSingleAccountDomain(t *testing.T) { + tests := []struct { + name string + account *types.Account + requested string + expectErr error + }{ + { + name: "usable account domain passes", + account: &types.Account{Id: "account-1", Domain: "acme.com"}, + }, + { + name: "empty account domain passes", + account: &types.Account{Id: "account-1"}, + }, + { + name: "unresolvable account domain fails", + account: &types.Account{Id: "account-1", Domain: "corp"}, + expectErr: ErrUnusableDomain, + }, + { + name: "conflicting request fails", + account: &types.Account{Id: "account-1", Domain: "acme.com"}, + requested: "corp.example.com", + expectErr: ErrDomainConflict, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &testStore{ + accountsCounter: 1, + accounts: map[string]*types.Account{tt.account.Id: tt.account}, + } + + err := CheckSingleAccountDomain(&testServer{store: store}, tt.requested) + if tt.expectErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tt.expectErr) + } + + assert.Empty(t, store.domainAttrCalls, "The preflight must not write anything") + }) + } +} diff --git a/management/server/idp/migration/store.go b/management/server/idp/migration/store.go index e7cc54a41..868597a1d 100644 --- a/management/server/idp/migration/store.go +++ b/management/server/idp/migration/store.go @@ -60,6 +60,20 @@ type Store interface { // CheckSchema verifies that all tables and columns required by the migration // exist in the database. Returns a list of problems; an empty slice means OK. CheckSchema(checks []SchemaCheck) []SchemaError + + // GetAccountsCounter returns the total number of accounts in the store. + GetAccountsCounter(ctx context.Context) (int64, error) + + // GetAnyAccountID returns the ID of one of the existing accounts. + GetAnyAccountID(ctx context.Context) (string, error) + + // IsPrimaryAccount returns whether the account is the primary account for its domain, + // along with that domain. + IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error) + + // UpdateAccountDomainAttributes sets the domain, domain category and primary + // domain flag of an account. + UpdateAccountDomainAttributes(ctx context.Context, accountID string, domain string, category string, isPrimaryDomain bool) error } // RequiredEventSchema lists all tables and columns that the migration tool needs diff --git a/tools/idp-migrate/DEVELOPMENT.md b/tools/idp-migrate/DEVELOPMENT.md index 5697ead40..41b5bc992 100644 --- a/tools/idp-migrate/DEVELOPMENT.md +++ b/tools/idp-migrate/DEVELOPMENT.md @@ -50,6 +50,7 @@ The build requires `CGO_ENABLED=1` because it links the SQLite driver used by `S | `--domain` | string | `""` | Sets both dashboard and API domain (convenience shorthand) | | `--dashboard-domain` | string | *(required)* | Dashboard domain (for redirect URIs) | | `--api-domain` | string | *(required)* | API domain (for Dex issuer and callback URLs) | +| `--single-account-mode-domain` | string | `netbird.selfhosted` | Domain single account mode groups users under. Used only when the account has no domain of its own; passing one that conflicts with the account's existing domain is an error | | `--dry-run` | bool | `false` | Preview changes without writing | | `--force` | bool | `false` | Skip interactive confirmation prompt | | `--skip-config` | bool | `false` | Skip config generation (DB-only migration) | @@ -68,6 +69,7 @@ All flags can be overridden via environment variables. Env vars take precedence | `NETBIRD_CONFIG_PATH` | `--config` | | `NETBIRD_DATA_DIR` | `--datadir` | | `NETBIRD_IDP_SEED_INFO` | `--idp-seed-info` | +| `NETBIRD_SINGLE_ACCOUNT_MODE_DOMAIN` | `--single-account-mode-domain` | | `NETBIRD_DRY_RUN` | `--dry-run` (set to `"true"`) | | `NETBIRD_FORCE` | `--force` (set to `"true"`) | | `NETBIRD_SKIP_CONFIG` | `--skip-config` (set to `"true"`) | diff --git a/tools/idp-migrate/config.go b/tools/idp-migrate/config.go index f4d6b9ea2..6510fd940 100644 --- a/tools/idp-migrate/config.go +++ b/tools/idp-migrate/config.go @@ -6,16 +6,18 @@ import ( "os" "strconv" + "github.com/netbirdio/netbird/management/server/idp/migration" "github.com/netbirdio/netbird/util" ) type migrationConfig struct { // Data - dashboardURL string - apiURL string - configPath string - dataDir string - idpSeedInfo string + dashboardURL string + apiURL string + configPath string + dataDir string + idpSeedInfo string + singleAccountDomain string // Options dryRun bool @@ -51,6 +53,7 @@ func configFromArgs(args []string) (*migrationConfig, error) { fs.StringVar(&cfg.configPath, "config", "", "path to management.json (required)") fs.StringVar(&cfg.dataDir, "datadir", "", "override data directory from config") fs.StringVar(&cfg.idpSeedInfo, "idp-seed-info", "", "base64-encoded connector JSON (overrides auto-detection)") + fs.StringVar(&cfg.singleAccountDomain, "single-account-mode-domain", "", "domain single account mode groups users under, used only when the account has no domain of its own (default "+migration.DefaultSingleAccountDomain+")") fs.BoolVar(&cfg.dryRun, "dry-run", false, "preview changes without writing") fs.BoolVar(&cfg.force, "force", false, "skip confirmation prompt") fs.BoolVar(&cfg.skipConfig, "skip-config", false, "skip config generation (DB migration only)") @@ -118,6 +121,10 @@ func applyOverrides(cfg *migrationConfig, domain string) { cfg.idpSeedInfo = val } + if val, ok := os.LookupEnv("NETBIRD_SINGLE_ACCOUNT_MODE_DOMAIN"); ok { + cfg.singleAccountDomain = val + } + // Enforce dry run if any value is provided if sval, ok := os.LookupEnv("NETBIRD_DRY_RUN"); ok { if val, err := strconv.ParseBool(sval); err == nil { @@ -170,5 +177,9 @@ func validateConfig(cfg *migrationConfig) error { return fmt.Errorf("--dashboard-domain is required") } + if _, err := migration.NormalizeSingleAccountDomain(cfg.singleAccountDomain); err != nil { + return err + } + return nil } diff --git a/tools/idp-migrate/main.go b/tools/idp-migrate/main.go index a8cba0750..652bf3393 100644 --- a/tools/idp-migrate/main.go +++ b/tools/idp-migrate/main.go @@ -71,6 +71,10 @@ func run(cfg *migrationConfig) error { return err } + if err := preflightAccounts(cfg, mgmtConfig); err != nil { + return err + } + if !cfg.skipPopulateUserInfo { err := populateUserInfoFromIDP(cfg, mgmtConfig) if err != nil { @@ -102,6 +106,22 @@ func run(cfg *migrationConfig) error { return generateConfig(cfg, connectorConfig) } +func preflightAccounts(cfg *migrationConfig, mgmtConfig *nbconfig.Config) error { + ctx := context.Background() + migStore, migEventStore, cleanup, err := openStores(ctx, mgmtConfig, cfg.dataDir) + if err != nil { + return err + } + defer cleanup() + + srv := &migrationServer{store: migStore, eventStore: migEventStore} + if err := migration.RequireSingleAccount(srv); err != nil { + return err + } + + return migration.CheckSingleAccountDomain(srv, cfg.singleAccountDomain) +} + // validateSchema opens the store and checks that all required tables and columns // exist. If anything is missing, it returns a descriptive error telling the user // to upgrade their management server. @@ -224,6 +244,8 @@ func migrateDB(cfg *migrationConfig, mgmtConfig *nbconfig.Config, connectorConfi } defer cleanup() + srv := &migrationServer{store: migStore, eventStore: migEventStore} + pending, err := previewUsers(ctx, migStore) if err != nil { return err @@ -243,11 +265,14 @@ func migrateDB(cfg *migrationConfig, mgmtConfig *nbconfig.Config, connectorConfi } } - srv := &migrationServer{store: migStore, eventStore: migEventStore} if err := migration.MigrateUsersToStaticConnectors(srv, connectorConfig); err != nil { return fmt.Errorf("migrate users: %w", err) } + if err := migration.EnsureSingleAccountDomain(srv, cfg.singleAccountDomain); err != nil { + return fmt.Errorf("prepare single account mode: %w", err) + } + if !cfg.dryRun { log.Info("DB migration completed successfully") } diff --git a/tools/idp-migrate/main_test.go b/tools/idp-migrate/main_test.go index 75d0bd7eb..286e15b88 100644 --- a/tools/idp-migrate/main_test.go +++ b/tools/idp-migrate/main_test.go @@ -485,3 +485,77 @@ func TestGenerateConfig(t *testing.T) { assert.True(t, os.IsNotExist(err)) }) } + +func TestValidateConfigRejectsUnusableSingleAccountDomain(t *testing.T) { + base := func() migrationConfig { + return migrationConfig{ + configPath: "/tmp/management.json", + dataDir: "/tmp/datadir", + idpSeedInfo: "seed", + apiURL: "https://api.example.com", + dashboardURL: "https://app.example.com", + singleAccountDomain: migration.DefaultSingleAccountDomain, + } + } + + t.Run("usable domain is accepted", func(t *testing.T) { + cfg := base() + require.NoError(t, validateConfig(&cfg)) + }) + + t.Run("empty falls back to the default", func(t *testing.T) { + cfg := base() + cfg.singleAccountDomain = "" + require.NoError(t, validateConfig(&cfg)) + }) + + // Rejected up front so the migration cannot fail after it has rewritten user IDs. + t.Run("single label domain is rejected", func(t *testing.T) { + cfg := base() + cfg.singleAccountDomain = "corp" + err := validateConfig(&cfg) + require.Error(t, err) + assert.ErrorIs(t, err, migration.ErrUnusableDomain) + }) +} + +func TestApplyOverrides_SingleAccountDomainFromEnv(t *testing.T) { + t.Run("env var overrides the flag", func(t *testing.T) { + t.Setenv("NETBIRD_SINGLE_ACCOUNT_MODE_DOMAIN", "corp.example.com") + + cfg, err := configFromArgs([]string{ + "--config", "/tmp/management.json", + "--datadir", "/tmp/datadir", + "--idp-seed-info", "seed", + "--domain", "example.com", + "--single-account-mode-domain", "flag.example.com", + }) + require.NoError(t, err) + assert.Equal(t, "corp.example.com", cfg.singleAccountDomain) + }) + + t.Run("unset leaves the flag value", func(t *testing.T) { + cfg, err := configFromArgs([]string{ + "--config", "/tmp/management.json", + "--datadir", "/tmp/datadir", + "--idp-seed-info", "seed", + "--domain", "example.com", + "--single-account-mode-domain", "flag.example.com", + }) + require.NoError(t, err) + assert.Equal(t, "flag.example.com", cfg.singleAccountDomain) + }) + + t.Run("unusable env value is rejected", func(t *testing.T) { + t.Setenv("NETBIRD_SINGLE_ACCOUNT_MODE_DOMAIN", "corp") + + _, err := configFromArgs([]string{ + "--config", "/tmp/management.json", + "--datadir", "/tmp/datadir", + "--idp-seed-info", "seed", + "--domain", "example.com", + }) + require.Error(t, err) + assert.ErrorIs(t, err, migration.ErrUnusableDomain) + }) +} From 0bdfa4277eb668ee599e8fe3a8b29fe5a7f51647 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:10:51 +0200 Subject: [PATCH 10/63] [management] blocking sync requests for user peers sharing the same wireguard key (#7427) --- management/internals/shared/grpc/server.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 844ae42db..a9cc0ad36 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -247,17 +247,8 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S sRealIP := realIP.String() peerMeta := extractPeerMeta(ctx, syncReq.GetMeta()) - userID, err := s.accountManager.GetUserIDByPeerKey(ctx, peerKey.String()) - if err != nil { - s.syncSem.Add(-1) - if errStatus, ok := internalStatus.FromError(err); ok && errStatus.Type() == internalStatus.NotFound { - return status.Errorf(codes.PermissionDenied, "peer is not registered") - } - return mapError(ctx, err) - } - metahashed := metaHash(peerMeta) - if userID == "" && !s.loginFilter.allowLogin(peerKey.String(), metahashed) { + if !s.loginFilter.allowLogin(peerKey.String(), metahashed) { if s.appMetrics != nil { s.appMetrics.GRPCMetrics().CountSyncRequestBlocked() } From 825389818ce905dd6a80cdb41cbbeb90bbfae5af Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 4 Sep 2026 15:07:01 +0200 Subject: [PATCH 11/63] [client] Gather fresh system info on every management sync stream connect (#7409) * Gather fresh system info on every management sync stream connect The engine collected the peer meta once at start and reused the same Info for every Sync stream reconnect, so a mobile network switch that redials management kept reporting the old local network addresses. The peer network range posture check was then evaluated against stale data until the client restarted. Sync now takes a gatherer that runs at each stream connect. The gatherer is cheap: GetInfo plus the cached posture check file results, kept in the new system.InfoSource, which the engine refreshes whenever the checks list changes. No process enumeration runs on the reconnect path. Also fix the management mock server calling itself instead of SyncFunc. * Evaluate the login response posture checks before the first sync connect The engine starts with the checks the login response carried, and the first sync stream request used to send their evaluated file results. After moving the gather into InfoSource, the stream opened with an empty cache and the first sync response did not refill it, because its checks equal the ones the engine already holds. Desktop peers therefore never reported process or file posture results. Seed the cache once before the first connect, where the old gather ran, so a timed out evaluation still falls through to the address-only info. * Harden the sync info source against nil callbacks and shared slices A nil getInfo opens the stream without metadata, as a nil sysInfo did before. The cached posture results are a copy, so the Info returned by Refresh cannot alias the snapshot later Current calls report. The exclusion test asserts the remaining address count so it cannot pass vacuously on a single-address host. * Retry a posture check refresh that timed out or failed to sync The checks list was recorded before the gather ran, so once the gather timed out or SyncMeta failed, the next sync response carrying the same list matched the recorded one and nothing retried. The peer kept reporting the previous posture results until the list changed again. Record the checks only after the meta reached management, so a failed cycle is repeated on the next sync response. * Log the skipped posture refresh, let the mock Sync return errors and deflake the reconnect test * Drop the nil guard around the sync info callback * Send the refreshed info on the first sync connect instead of gathering it twice --- client/internal/engine.go | 39 ++++-- client/internal/engine_privileged_test.go | 2 +- client/internal/engine_test.go | 114 ++++++++++++++++++ client/system/info_source.go | 38 ++++++ client/system/info_source_test.go | 59 +++++++++ .../mock_server/management_server_mock.go | 4 +- shared/management/client/client.go | 2 +- shared/management/client/client_test.go | 73 ++++++++++- shared/management/client/grpc.go | 8 +- shared/management/client/mock.go | 6 +- 10 files changed, 323 insertions(+), 22 deletions(-) create mode 100644 client/system/info_source.go create mode 100644 client/system/info_source_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index 477fba194..f8b65f7d8 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -265,6 +265,8 @@ type Engine struct { // checks are the client-applied posture checks that need to be evaluated on the client checks []*mgmProto.Checks + infoSource system.InfoSource + relayManager *relayClient.Manager stateManager *statemanager.Manager portForwardManager *portforward.Manager @@ -1241,9 +1243,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { if isChecksEqual(e.checks, checks) { return nil } - e.checks = checks - - info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...) + info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...) if !ok { // Gathering timed out; skip the meta sync this cycle rather than blocking the // sync loop (and syncMsgMux) on a stuck system call. A later sync will retry. @@ -1254,6 +1254,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { if err := e.mgmClient.SyncMeta(info); err != nil { return fmt.Errorf("could not sync meta: error %s", err) } + e.checks = checks return nil } @@ -1280,6 +1281,28 @@ func (e *Engine) applyInfoFlags(info *system.Info) { ) } +func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info { + info := e.infoSource.Current(ctx, e.overlayAddresses()...) + e.applyInfoFlags(info) + return info +} + +// syncInfoFunc returns the info callback for the management sync stream. The +// first connect sends the info refreshed right before it instead of gathering +// again; every reconnect gathers a fresh one. The stream retry loop calls the +// callback sequentially, so the handoff needs no synchronization. +func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context) *system.Info { + return func(ctx context.Context) *system.Info { + if refreshed == nil { + return e.currentSystemInfo(ctx) + } + info := refreshed + refreshed = nil + e.applyInfoFlags(info) + return info + } +} + // overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it // can be excluded from the reported network addresses; the interface coming and // going otherwise churns the peer meta on the management server. @@ -1473,15 +1496,11 @@ func (e *Engine) receiveManagementEvents() { e.shutdownWg.Add(1) go func() { defer e.shutdownWg.Done() - info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...) + info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...) if !ok { - // Gathering timed out; connect the stream with base info so management - // connectivity still comes up rather than blocking here. - info = system.GetInfo(e.ctx) + log.Warnf("posture checks not refreshed before the sync connect, sending the previous results") } - e.applyInfoFlags(info) - - err := e.mgmClient.Sync(e.ctx, info, e.handleSync) + err := e.mgmClient.Sync(e.ctx, e.syncInfoFunc(info), e.handleSync) if err != nil { // happens if management is unavailable for a long time. // We want to cancel the operation of the whole client diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index 1428b742c..1b047e017 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -193,7 +193,7 @@ func TestEngine_Sync(t *testing.T) { // feed updates to Engine via mocked Management client updates := make(chan *mgmtProto.SyncResponse) defer close(updates) - syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { + syncFunc := func(ctx context.Context, _ func(context.Context) *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { for msg := range updates { err := msgHandler(msg) if err != nil { diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 4e9faa437..ec388ac94 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -2,6 +2,7 @@ package internal import ( "context" + "errors" "fmt" "net" "net/netip" @@ -31,6 +32,7 @@ import ( icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/routemanager" + "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/monotime" "github.com/netbirdio/netbird/route" @@ -253,6 +255,118 @@ func TestEngine_SSHServerConsistency(t *testing.T) { }) } +func TestEngine_FirstSyncInfoCarriesLoginChecks(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + exe, err := os.Executable() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + infos := make(chan *system.Info, 1) + mgmClient := &mgmt.MockClient{ + SyncFunc: func(ctx context.Context, getInfo func(context.Context) *system.Info, _ func(*mgmtProto.SyncResponse) error) error { + infos <- getInfo(ctx) + return nil + }, + } + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun104", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: mgmClient, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + Checks: []*mgmtProto.Checks{{Files: []string{exe}}}, + }, MobileDependency{}) + + engine.receiveManagementEvents() + + select { + case info := <-infos: + require.Len(t, info.Files, 1) + assert.Equal(t, exe, info.Files[0].Path) + assert.True(t, info.Files[0].Exist) + case <-time.After(20 * time.Second): + t.Fatal("timeout waiting for the first sync info") + } + engine.shutdownWg.Wait() +} + +func TestEngine_SyncInfoFuncReusesRefreshedInfoOnce(t *testing.T) { + engine := &Engine{config: &EngineConfig{}} + + refreshed := &system.Info{Hostname: "from-refresh"} + getInfo := engine.syncInfoFunc(refreshed) + + first := getInfo(context.Background()) + assert.Same(t, refreshed, first, "the first connect should send the refreshed info instead of gathering again") + + second := getInfo(context.Background()) + assert.NotSame(t, refreshed, second, "the reconnect should gather a fresh info") + assert.NotEqual(t, "from-refresh", second.Hostname, "the fresh info should not carry the refreshed hostname") +} + +func TestEngine_SyncInfoFuncGathersWhenRefreshFailed(t *testing.T) { + engine := &Engine{config: &EngineConfig{}} + + info := engine.syncInfoFunc(nil)(context.Background()) + require.NotNil(t, info, "a failed refresh should fall back to gathering the info") + assert.NotEmpty(t, info.Hostname, "the gathered info should carry the hostname") +} + +func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + exe, err := os.Executable() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + syncMetaCalls := 0 + mgmClient := &mgmt.MockClient{ + SyncMetaFunc: func(*system.Info) error { + syncMetaCalls++ + if syncMetaCalls == 1 { + return errors.New("management unavailable") + } + return nil + }, + } + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun105", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: mgmClient, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) + + checks := []*mgmtProto.Checks{{Files: []string{exe}}} + + require.Error(t, engine.updateChecksIfNew(checks)) + require.NoError(t, engine.updateChecksIfNew(checks)) + require.NoError(t, engine.updateChecksIfNew(checks)) + + assert.Equal(t, 2, syncMetaCalls) +} + func TestEngine_UpdateNetworkMap(t *testing.T) { // test setup key, err := wgtypes.GeneratePrivateKey() diff --git a/client/system/info_source.go b/client/system/info_source.go new file mode 100644 index 000000000..050e094e4 --- /dev/null +++ b/client/system/info_source.go @@ -0,0 +1,38 @@ +package system + +import ( + "context" + "net/netip" + "slices" + "sync/atomic" + "time" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +// InfoSource gathers the system info sent to management, keeping the posture +// check results from the last Refresh for the cheap Current snapshots. +type InfoSource struct { + files atomic.Pointer[[]File] +} + +// Refresh gathers the info with the posture checks evaluated, bounded by timeout. +func (s *InfoSource) Refresh(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) { + info, ok := GetInfoWithChecksTimeout(ctx, timeout, checks, excludeIPs...) + if !ok { + return nil, false + } + files := slices.Clone(info.Files) + s.files.Store(&files) + return info, true +} + +// Current gathers the info without evaluating the checks, reusing the last Refresh results. +func (s *InfoSource) Current(ctx context.Context, excludeIPs ...netip.Addr) *Info { + info := GetInfo(ctx) + info.removeAddresses(excludeIPs...) + if files := s.files.Load(); files != nil { + info.Files = *files + } + return info +} diff --git a/client/system/info_source_test.go b/client/system/info_source_test.go new file mode 100644 index 000000000..1c86806af --- /dev/null +++ b/client/system/info_source_test.go @@ -0,0 +1,59 @@ +package system + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestInfoSource_CurrentBeforeRefresh(t *testing.T) { + var src InfoSource + + info := src.Current(context.Background()) + + assert.Empty(t, info.Files) +} + +func TestInfoSource_CurrentReusesRefreshedFiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + checks := []*proto.Checks{{Files: []string{path}}} + + var src InfoSource + refreshed, ok := src.Refresh(context.Background(), 15*time.Second, checks) + require.True(t, ok) + require.Equal(t, []File{{Path: path, Exist: true}}, refreshed.Files) + + info := src.Current(context.Background()) + + assert.Equal(t, refreshed.Files, info.Files) +} + +func TestInfoSource_CurrentExcludesAddresses(t *testing.T) { + addrs := GetInfo(context.Background()).NetworkAddresses + if len(addrs) == 0 { + t.Skip("no network addresses on this host") + } + excluded := addrs[0].NetIP.Addr() + matching := 0 + for _, addr := range addrs { + if addr.NetIP.Addr() == excluded { + matching++ + } + } + + var src InfoSource + info := src.Current(context.Background(), excluded) + + assert.Len(t, info.NetworkAddresses, len(addrs)-matching) + for _, addr := range info.NetworkAddresses { + assert.NotEqual(t, excluded, addr.NetIP.Addr()) + } +} diff --git a/management/server/mock_server/management_server_mock.go b/management/server/mock_server/management_server_mock.go index 45049f1fe..cd219b8a9 100644 --- a/management/server/mock_server/management_server_mock.go +++ b/management/server/mock_server/management_server_mock.go @@ -13,7 +13,7 @@ type ManagementServiceServerMock struct { proto.UnimplementedManagementServiceServer LoginFunc func(context.Context, *proto.EncryptedMessage) (*proto.EncryptedMessage, error) - SyncFunc func(*proto.EncryptedMessage, proto.ManagementService_SyncServer) + SyncFunc func(*proto.EncryptedMessage, proto.ManagementService_SyncServer) error GetServerKeyFunc func(context.Context, *proto.Empty) (*proto.ServerKeyResponse, error) IsHealthyFunc func(context.Context, *proto.Empty) (*proto.Empty, error) GetDeviceAuthorizationFlowFunc func(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) @@ -30,7 +30,7 @@ func (m ManagementServiceServerMock) Login(ctx context.Context, req *proto.Encry func (m ManagementServiceServerMock) Sync(msg *proto.EncryptedMessage, sync proto.ManagementService_SyncServer) error { if m.SyncFunc != nil { - return m.Sync(msg, sync) + return m.SyncFunc(msg, sync) } return status.Errorf(codes.Unimplemented, "method Sync not implemented") } diff --git a/shared/management/client/client.go b/shared/management/client/client.go index c48e1ed3e..13beabee6 100644 --- a/shared/management/client/client.go +++ b/shared/management/client/client.go @@ -12,7 +12,7 @@ import ( // Client is the interface for the management service client. type Client interface { io.Closer - Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error + Sync(ctx context.Context, getInfo func(ctx context.Context) *system.Info, msgHandler func(msg *proto.SyncResponse) error) error Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error Register(setupKey string, jwtToken string, sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error) Login(sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error) diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index d91dab221..e6335dccb 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -2,9 +2,11 @@ package client import ( "context" + "fmt" "net" "os" "sync" + "sync/atomic" "testing" "time" @@ -305,7 +307,7 @@ func TestClient_Sync(t *testing.T) { defer cancel() go func() { - err = client.Sync(ctx, info, func(msg *mgmtProto.SyncResponse) error { + err = client.Sync(ctx, func(context.Context) *system.Info { return info }, func(msg *mgmtProto.SyncResponse) error { ch <- msg return nil }) @@ -397,6 +399,75 @@ func wgKeyFromBytes(raw []byte) string { return k.String() } +func TestClient_SyncGathersInfoOnEveryConnect(t *testing.T) { + s, lis, mgmtMockServer, serverKey := startMockManagement(t) + defer s.GracefulStop() + + testKey, err := wgtypes.GenerateKey() + require.NoError(t, err) + + hostnames := make(chan string, 2) + mgmtMockServer.SyncFunc = func(msg *mgmtProto.EncryptedMessage, _ mgmtProto.ManagementService_SyncServer) error { + peerKey, err := wgtypes.ParseKey(msg.GetWgPubKey()) + if err != nil { + t.Errorf("invalid peer key: %v", err) + return status.Error(codes.InvalidArgument, err.Error()) + } + syncReq := &mgmtProto.SyncRequest{} + if err := encryption.DecryptMessage(peerKey, serverKey, msg.Body, syncReq); err != nil { + t.Errorf("decrypt sync request: %v", err) + return status.Error(codes.InvalidArgument, err.Error()) + } + select { + case hostnames <- syncReq.GetMeta().GetHostname(): + default: + } + // Returning closes the stream, so the client reconnects and gathers again. + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client, err := NewClient(ctx, lis.Addr().String(), testKey, false) + require.NoError(t, err) + + var gathers atomic.Int32 + done := make(chan struct{}) + go func() { + defer close(done) + _ = client.Sync(ctx, func(ctx context.Context) *system.Info { + info := system.GetInfo(ctx) + info.Hostname = fmt.Sprintf("host-%d", gathers.Add(1)) + return info + }, func(*mgmtProto.SyncResponse) error { return nil }) + }() + + // A connect attempt can fail before it reaches the server, so the sequence + // numbers seen here may skip. What matters is that the reconnect carries a + // newly gathered info instead of the one sent on the previous stream. + var seen []int + for len(seen) < 2 { + select { + case got := <-hostnames: + var n int + _, err := fmt.Sscanf(got, "host-%d", &n) + require.NoError(t, err, "hostname should carry the gather sequence number") + seen = append(seen, n) + case <-time.After(10 * time.Second): + t.Fatalf("timeout waiting for the second sync request, got %v", seen) + } + } + assert.Greater(t, seen[1], seen[0], "the reconnect should carry a newly gathered info") + + cancel() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for Sync to return after cancel") + } +} + func Test_SystemMetaDataFromClient(t *testing.T) { s, lis, mgmtMockServer, serverKey := startMockManagement(t) defer s.GracefulStop() diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index efea47df0..ce2d07429 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -205,9 +205,9 @@ func (c *GrpcClient) ready() bool { // Sync wraps the real client's Sync endpoint call and takes care of retries and encryption/decryption of messages // Blocking request. The result will be sent via msgHandler callback function -func (c *GrpcClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { +func (c *GrpcClient) Sync(ctx context.Context, getInfo func(ctx context.Context) *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { return c.withMgmtStream(ctx, func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error { - return c.handleSyncStream(ctx, serverPubKey, sysInfo, msgHandler, backOff) + return c.handleSyncStream(ctx, serverPubKey, getInfo, msgHandler, backOff) }) } @@ -424,11 +424,11 @@ func (c *GrpcClient) sendJobResponse( return nil } -func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error, backOff backoff.BackOff) error { +func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.Key, getInfo func(ctx context.Context) *system.Info, msgHandler func(msg *proto.SyncResponse) error, backOff backoff.BackOff) error { ctx, cancelStream := context.WithCancel(ctx) defer cancelStream() - stream, err := c.connectToSyncStream(ctx, serverPubKey, sysInfo) + stream, err := c.connectToSyncStream(ctx, serverPubKey, getInfo(ctx)) if err != nil { log.Debugf("failed to open Management Service stream: %s", err) c.notifyDisconnected(err) diff --git a/shared/management/client/mock.go b/shared/management/client/mock.go index e57e314da..28278dcab 100644 --- a/shared/management/client/mock.go +++ b/shared/management/client/mock.go @@ -11,7 +11,7 @@ import ( // MockClient is a mock implementation of the Client interface for testing. type MockClient struct { CloseFunc func() error - SyncFunc func(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error + SyncFunc func(ctx context.Context, getInfo func(ctx context.Context) *system.Info, msgHandler func(msg *proto.SyncResponse) error) error RegisterFunc func(setupKey string, jwtToken string, info *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error) LoginFunc func(info *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error) ExtendAuthSessionFunc func(info *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error) @@ -38,11 +38,11 @@ func (m *MockClient) Close() error { return m.CloseFunc() } -func (m *MockClient) Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { +func (m *MockClient) Sync(ctx context.Context, getInfo func(ctx context.Context) *system.Info, msgHandler func(msg *proto.SyncResponse) error) error { if m.SyncFunc == nil { return nil } - return m.SyncFunc(ctx, sysInfo, msgHandler) + return m.SyncFunc(ctx, getInfo, msgHandler) } func (m *MockClient) Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error { From 5cb6b0d33b0cc5fb112470d13dcd6b3b2d087415 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 4 Sep 2026 15:10:36 +0200 Subject: [PATCH 12/63] [client] Assign the Android TUN address as a host prefix (#7414) Android 16+ local network protection derives the blocked prefixes from the interface address prefix. A /16 address turns the whole overlay into a local network, so apps without ACCESS_LOCAL_NETWORK cannot reach any peer. Pass the address as /32 and /128 and add the overlay networks to the route list that the Android side turns into VPN routes, on both the initial create and the renew path. --- client/iface/device/device_android.go | 7 +- client/iface/wgaddr/address.go | 13 +++ client/iface/wgaddr/address_test.go | 24 +++++ client/internal/routemanager/manager.go | 59 ++++++++---- .../internal/routemanager/reconcile_test.go | 7 +- .../internal/routemanager/route_range_test.go | 95 +++++++++++++++++++ 6 files changed, 183 insertions(+), 22 deletions(-) create mode 100644 client/iface/wgaddr/address_test.go create mode 100644 client/internal/routemanager/route_range_test.go diff --git a/client/iface/device/device_android.go b/client/iface/device/device_android.go index cbe88c10c..0ed1299ae 100644 --- a/client/iface/device/device_android.go +++ b/client/iface/device/device_android.go @@ -63,7 +63,12 @@ func (t *WGTunDevice) Create(routes []string, dns string, searchDomains []string searchDomainsToString = "" } - fd, err := t.tunAdapter.ConfigureInterface(t.address.String(), t.address.IPv6String(), int(t.mtu), dns, searchDomainsToString, routesString) + ipv6Host := "" + if t.address.HasIPv6() { + ipv6Host = t.address.IPv6HostPrefix().String() + } + + fd, err := t.tunAdapter.ConfigureInterface(t.address.HostPrefix().String(), ipv6Host, int(t.mtu), dns, searchDomainsToString, routesString) if err != nil { log.Errorf("failed to create Android interface: %s", err) return nil, err diff --git a/client/iface/wgaddr/address.go b/client/iface/wgaddr/address.go index 43d1ec9aa..148e724f4 100644 --- a/client/iface/wgaddr/address.go +++ b/client/iface/wgaddr/address.go @@ -59,6 +59,19 @@ func (addr Address) IPv6Prefix() netip.Prefix { return netip.PrefixFrom(addr.IPv6, addr.IPv6Net.Bits()) } +// HostPrefix returns the v4 address as a single-host prefix. +func (addr Address) HostPrefix() netip.Prefix { + return netip.PrefixFrom(addr.IP, addr.IP.BitLen()) +} + +// IPv6HostPrefix returns the v6 address as a single-host prefix, or an invalid prefix when no v6 overlay address is assigned. +func (addr Address) IPv6HostPrefix() netip.Prefix { + if !addr.HasIPv6() { + return netip.Prefix{} + } + return netip.PrefixFrom(addr.IPv6, addr.IPv6.BitLen()) +} + // SetIPv6FromCompact decodes a compact prefix (5 or 17 bytes) and sets the IPv6 fields. // Returns an error if the bytes are invalid. A nil or empty input is a no-op. // diff --git a/client/iface/wgaddr/address_test.go b/client/iface/wgaddr/address_test.go new file mode 100644 index 000000000..61478b24c --- /dev/null +++ b/client/iface/wgaddr/address_test.go @@ -0,0 +1,24 @@ +package wgaddr + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddress_HostPrefix(t *testing.T) { + addr := MustParseWGAddress("100.91.96.107/16") + + assert.Equal(t, netip.MustParsePrefix("100.91.96.107/32"), addr.HostPrefix(), "v4 host prefix must be a single host") + assert.Equal(t, netip.MustParsePrefix("100.91.0.0/16"), addr.Network, "network must keep the overlay prefix length") + assert.False(t, addr.IPv6HostPrefix().IsValid(), "no v6 overlay means no v6 host prefix") +} + +func TestAddress_IPv6HostPrefix(t *testing.T) { + addr := MustParseWGAddress("100.91.96.107/16") + addr.IPv6 = netip.MustParseAddr("fd00:1234::1") + addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64") + + assert.Equal(t, netip.MustParsePrefix("fd00:1234::1/128"), addr.IPv6HostPrefix(), "v6 host prefix must be a single host") +} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 0ccfa83ac..981b0c987 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -8,6 +8,7 @@ import ( "net/netip" "net/url" "runtime" + "slices" "sort" "strings" "sync" @@ -472,27 +473,13 @@ func (m *DefaultManager) CurrentRouteRange() []string { m.mux.Lock() defer m.mux.Unlock() - if m.disableClientRoutes { - return nil - } - - filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes) - var nets []string - for _, routes := range filtered { - for _, r := range routes { - if r.IsDynamic() { - continue - } - nets = append(nets, r.NetString()) - } - } - - if m.fakeIPManager != nil { - nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String()) + nets := m.overlayNetworks() + if !m.disableClientRoutes { + nets = append(nets, m.clientRouteRange()...) } sort.Strings(nets) - return nets + return slices.Compact(nets) } // GetRouteSelector returns the route selector @@ -856,6 +843,42 @@ func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.Ne len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement)) } +// overlayNetworks returns the v4 and v6 overlay networks of the WireGuard interface, each only when it is set. +func (m *DefaultManager) overlayNetworks() []string { + if m.wgInterface == nil { + return nil + } + + addr := m.wgInterface.Address() + var nets []string + if addr.Network.IsValid() { + nets = append(nets, addr.Network.String()) + } + if addr.IPv6Net.IsValid() { + nets = append(nets, addr.IPv6Net.String()) + } + return nets +} + +// clientRouteRange returns the static client route networks of the selected exit nodes together with the fake IP blocks. +func (m *DefaultManager) clientRouteRange() []string { + filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes) + var nets []string + for _, routes := range filtered { + for _, r := range routes { + if r.IsDynamic() { + continue + } + nets = append(nets, r.NetString()) + } + } + + if m.fakeIPManager != nil { + nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String()) + } + return nets +} + // minNetID returns the lexicographically smallest NetID, for a deterministic // default pick that stays stable across restarts. func minNetID(ids []route.NetID) route.NetID { diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go index c6806a6cd..bc9693229 100644 --- a/client/internal/routemanager/reconcile_test.go +++ b/client/internal/routemanager/reconcile_test.go @@ -17,11 +17,12 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" ) -// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other -// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them. +// reconcileWGMock is a minimal iface.WGIface that records AddAllowedIP calls and reports the +// configured address; every other method is an inert stub because the tests exercise none of them. type reconcileWGMock struct { mu sync.Mutex adds map[string][]netip.Prefix + addr wgaddr.Address } func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { @@ -42,7 +43,7 @@ func (m *reconcileWGMock) added(peerKey string) []netip.Prefix { func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil } func (m *reconcileWGMock) Name() string { return "utun-test" } -func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} } +func (m *reconcileWGMock) Address() wgaddr.Address { return m.addr } func (m *reconcileWGMock) ToInterface() *net.Interface { return nil } func (m *reconcileWGMock) IsUserspaceBind() bool { return false } func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil } diff --git a/client/internal/routemanager/route_range_test.go b/client/internal/routemanager/route_range_test.go new file mode 100644 index 000000000..b51b5747a --- /dev/null +++ b/client/internal/routemanager/route_range_test.go @@ -0,0 +1,95 @@ +//go:build !windows + +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func TestCurrentRouteRange_OverlayNetworkWithClientRoutesDisabled(t *testing.T) { + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")}, + disableClientRoutes: true, + } + + assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "overlay network must be routed even when client routes are disabled") +} + +func TestCurrentRouteRange_OverlayNetworksAndClientRoutes(t *testing.T) { + addr := wgaddr.MustParseWGAddress("100.91.96.107/16") + addr.IPv6 = netip.MustParseAddr("fd00:1234::1") + addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64") + + static := &route.Route{ID: "static", NetID: "lan", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network} + dynamic := &route.Route{ID: "dynamic", NetID: "dyn", NetworkType: route.DomainNetwork} + + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: addr}, + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + static.GetHAUniqueID(): {static}, + dynamic.GetHAUniqueID(): {dynamic}, + }, + } + + assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24", "fd00:1234::/64"}, m.CurrentRouteRange(), "overlay networks and static client routes must be listed, dynamic routes skipped") +} + +func TestCurrentRouteRange_NoInterfaceAddress(t *testing.T) { + m := &DefaultManager{ + wgInterface: &reconcileWGMock{}, + disableClientRoutes: true, + } + + assert.Empty(t, m.CurrentRouteRange(), "an unset interface address must not produce a route entry") +} + +func TestCurrentRouteRange_IPv6WithoutIPv4Network(t *testing.T) { + addr := wgaddr.Address{ + IPv6: netip.MustParseAddr("fd00:1234::1"), + IPv6Net: netip.MustParsePrefix("fd00:1234::/64"), + } + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: addr}, + disableClientRoutes: true, + } + + assert.Equal(t, []string{"fd00:1234::/64"}, m.CurrentRouteRange(), "a v6 overlay network must not depend on a v4 network being set") +} + +func TestCurrentRouteRange_IPv6AddressWithoutNetwork(t *testing.T) { + addr := wgaddr.MustParseWGAddress("100.91.96.107/16") + addr.IPv6 = netip.MustParseAddr("fd00:1234::1") + + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: addr}, + disableClientRoutes: true, + } + + assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "a v6 address without a network must not produce a route entry") +} + +func TestCurrentRouteRange_DeduplicatesPrefixes(t *testing.T) { + // Two HA peers serve the same prefix, and a client route announces the overlay network itself. + haPeerA := &route.Route{ID: "ha-a", NetID: "lan", Peer: "peer-a", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network} + haPeerB := &route.Route{ID: "ha-b", NetID: "lan", Peer: "peer-b", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network} + overlay := &route.Route{ID: "overlay", NetID: "overlay", Network: netip.MustParsePrefix("100.91.0.0/16"), NetworkType: route.IPv4Network} + + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")}, + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + haPeerA.GetHAUniqueID(): {haPeerA, haPeerB}, + overlay.GetHAUniqueID(): {overlay}, + }, + } + + assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24"}, m.CurrentRouteRange(), "every prefix must be listed once regardless of how many routes carry it") +} From 00003814f3561692b49569da771b9160075ae5d1 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Fri, 4 Sep 2026 15:29:51 +0200 Subject: [PATCH 13/63] [management] update network_router_test to verify empty and nil peer_groups (#7425) * update network_router_test to verify empty and nil peer_groups Signed-off-by: Dmitri Dolguikh * fix an issue with deserialization of nil json array in user.go Signed-off-by: Dmitri Dolguikh * order zones by id Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- .../network_map_db/network_router_test.go | 12 ++ .../management/network_map_db/user_test.go | 22 +++- .../network_router_store_test.go | 120 ------------------ .../internals/network_map_db/pgsql/dns.go | 1 + .../internals/network_map_db/sqlite/user.go | 11 +- 5 files changed, 36 insertions(+), 130 deletions(-) delete mode 100644 management/internals/network_map_db/network_router_store_test.go diff --git a/integration_tests/management/network_map_db/network_router_test.go b/integration_tests/management/network_map_db/network_router_test.go index fa7ea2a04..2baf146a7 100644 --- a/integration_tests/management/network_map_db/network_router_test.go +++ b/integration_tests/management/network_map_db/network_router_test.go @@ -19,6 +19,14 @@ func TestGetNetworkRouters(t *testing.T) { 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"]')`) + // empty peer_groups + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-3','account-1','public-id-3','peer-id-3','network-id-3',TRUE,999,TRUE,'[]')`) + // nil peer_groups + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-4','account-1','public-id-4','peer-id-4','network-id-4',TRUE,999,TRUE,null)`) routers, err := conn(t, ctx).GetNetworkRouters(ctx, "account-1") assert.NoError(t, err) @@ -30,4 +38,8 @@ func TestGetNetworkRouters(t *testing.T) { 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"}}}) + assert.Equal(t, routers["network-id-3"], + map[string]*nmdata.NetworkRouter{"peer-id-3": {PublicID: "public-id-3", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{}}}) + assert.Equal(t, routers["network-id-4"], + map[string]*nmdata.NetworkRouter{"peer-id-4": {PublicID: "public-id-4", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: nil}}) } diff --git a/integration_tests/management/network_map_db/user_test.go b/integration_tests/management/network_map_db/user_test.go index 132f749e2..fce1833d3 100644 --- a/integration_tests/management/network_map_db/user_test.go +++ b/integration_tests/management/network_map_db/user_test.go @@ -21,6 +21,14 @@ func TestGetAllowedUsers(t *testing.T) { 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)`) + // empty auto_groups; shouldn't error out + execQuery(t, ctx, + `insert into users (id, name, account_id, auto_groups, blocked, is_service_user) + VALUES('user-31','user-31','account-1','[]',false,false)`) + // null auto_groups; shouldn't error out + execQuery(t, ctx, + `insert into users (id, name, account_id, auto_groups, blocked, is_service_user) + VALUES('user-32','user-32','account-1',null,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) @@ -43,15 +51,17 @@ func TestGetAllowedUsers(t *testing.T) { assert.NoError(t, err) assert.Equal(t, userIdx, map[string]struct{}{ - "user-1": {}, - "user-2": {}, - "user-3": {}, + "user-1": {}, + "user-2": {}, + "user-3": {}, + "user-31": {}, + "user-32": {}, }) 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"}, + "all-group-1": {"user-1", "user-2", "user-3", "user-31", "user-32"}, + "all-group-2": {"user-1", "user-2", "user-3", "user-31", "user-32"}, + "all-group-3": {"user-1", "user-2", "user-3", "user-31", "user-32"}, }) } diff --git a/management/internals/network_map_db/network_router_store_test.go b/management/internals/network_map_db/network_router_store_test.go deleted file mode 100644 index d44aa1ebc..000000000 --- a/management/internals/network_map_db/network_router_store_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package networkmapdb_test - -import ( - "context" - "net/netip" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - 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" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/testutil" - "github.com/netbirdio/netbird/management/server/types" -) - -// newEngineStores opens both stores on the selected engine's database. -func newEngineStores(t *testing.T) (store.Store, networkmapdb.NetworkMapDBStore) { - t.Helper() - ctx := context.Background() - - switch engine := types.Engine(os.Getenv("NETBIRD_STORE_ENGINE")); engine { - case types.PostgresStoreEngine: - cleanup, dsn, err := testutil.CreatePostgresTestContainer() - require.NoError(t, err, "start postgres test container") - t.Cleanup(cleanup) - - accountStore, err := store.NewPostgresqlStore(ctx, dsn, nil, false) - require.NoError(t, err, "connect account store") - t.Cleanup(func() { _ = accountStore.Close(ctx) }) - - nmStore, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn) - require.NoError(t, err, "connect networkmap store") - t.Cleanup(func() { nmStore.Pool.Close() }) - return accountStore, nmStore - case types.SqliteStoreEngine, "": - dataDir := t.TempDir() - accountStore, err := store.NewSqliteStore(ctx, dataDir, nil, false) - require.NoError(t, err, "open account store") - t.Cleanup(func() { _ = accountStore.Close(ctx) }) - - nmStore, err := networkmap_sqlite.NewSqliteStore("store.db", dataDir) - require.NoError(t, err, "open networkmap store") - t.Cleanup(func() { _ = nmStore.Db.Close() }) - return accountStore, nmStore - default: - t.Skipf("networkmap store does not support engine %q", engine) - return nil, nil - } -} - -// Peer-based routers must survive the network-map read on every engine. -func TestGetNetworkRouters_ServesPeerBasedRouters(t *testing.T) { - ctx := context.Background() - accountStore, nmStore := newEngineStores(t) - - const ( - accountID = "acc-nmap-routers" - groupID = "grp-router-members" - memberID = "peer-member" - ) - - // Postgres enforces the groups-to-accounts FK that SQLite ignores. - require.NoError(t, accountStore.SaveAccount(ctx, &types.Account{ - Id: accountID, - Peers: map[string]*nbpeer.Peer{ - memberID: { - ID: memberID, - AccountID: accountID, - Key: memberID + "-key", - IP: netip.MustParseAddr("100.64.0.10"), - Status: &nbpeer.PeerStatus{}, - }, - }, - Groups: map[string]*types.Group{ - groupID: { - ID: groupID, - AccountID: accountID, - Name: "router members", - Issued: types.GroupIssuedAPI, - Peers: []string{memberID}, - }, - }, - }), "seed the account the routers belong to") - - routers := []*routerTypes.NetworkRouter{ - {ID: "router-peer-nil", AccountID: accountID, NetworkID: "net-peer-nil", PublicID: "pub-peer-nil", Peer: "peer-direct-nil", Enabled: true, Metric: 9999}, - {ID: "router-peer-empty", AccountID: accountID, NetworkID: "net-peer-empty", PublicID: "pub-peer-empty", Peer: "peer-direct-empty", PeerGroups: []string{}, Enabled: true, Metric: 9999}, - {ID: "router-group", AccountID: accountID, NetworkID: "net-group", PublicID: "pub-group", PeerGroups: []string{groupID}, Enabled: true, Metric: 9999}, - } - for _, router := range routers { - require.NoError(t, accountStore.CreateNetworkRouter(ctx, router)) - } - - tx, err := nmStore.BeginTx(ctx) - require.NoError(t, err, "begin networkmap read transaction") - t.Cleanup(func() { _ = tx.RollbackTx(ctx) }) - - got, err := tx.GetNetworkRouters(ctx, accountID) - require.NoError(t, err, "read network routers") - - assert.Contains(t, got, "net-peer-nil", - "a router referencing an individual peer (peer_groups stored as NULL) must reach the network map") - assert.Contains(t, got["net-peer-nil"], "peer-direct-nil", - "the individual-peer router must be keyed by its peer") - - assert.Contains(t, got, "net-peer-empty", - "a router referencing an individual peer (peer_groups stored as '[]') must reach the network map") - assert.Contains(t, got["net-peer-empty"], "peer-direct-empty", - "the individual-peer router must be keyed by its peer") - - assert.Contains(t, got, "net-group", "a group router must reach the network map") - assert.Contains(t, got["net-group"], memberID, - "the group router must fan out to the group's member peers") -} diff --git a/management/internals/network_map_db/pgsql/dns.go b/management/internals/network_map_db/pgsql/dns.go index b22b43903..46ef0ddfa 100644 --- a/management/internals/network_map_db/pgsql/dns.go +++ b/management/internals/network_map_db/pgsql/dns.go @@ -15,6 +15,7 @@ const ( from zones left join records as r on r.zone_id = zones.id where zones.account_id=$1 and zones.enabled + order by zones.id ` ) diff --git a/management/internals/network_map_db/sqlite/user.go b/management/internals/network_map_db/sqlite/user.go index 0bdda372e..23c46bf76 100644 --- a/management/internals/network_map_db/sqlite/user.go +++ b/management/internals/network_map_db/sqlite/user.go @@ -42,17 +42,20 @@ func (sc *SqliteStoreConn) GetAllowedUsers(ctx context.Context, accountId string userIdIdx := make(map[string]struct{}) groupIdToUserIds := make(map[string][]string) for _, user := range users { + for _, allgid := range allGroupIds { + groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID) + } + userIdIdx[user.ID] = struct{}{} autogroups := make([]string, 0) + if user.AutoGroups == nil { + continue + } 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 From 76ea72237f3346ff157aa8374b9fa800d6498976 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Fri, 4 Sep 2026 17:11:27 +0200 Subject: [PATCH 14/63] [management] Add Agent Network managed proxy to the API spec (#7433) Defines the cloud-side managed gateway provisioning surface (POST/GET /api/integrations/agent-network/managed-proxy) and its response objects so clients consume generated types instead of hand-written ones. POST is idempotent: 202 when the call starts (or restarts) provisioning, 200 when a deployment already exists; 409 names an already-assigned endpoint the managed flow does not own and 503 signals temporarily exhausted endpoint allocation. --- client/proto/generate.sh | 2 +- encryption/testprotos/generate.sh | 4 +- flow/proto/generate.sh | 2 +- shared/management/http/api/generate.sh | 2 +- shared/management/http/api/openapi.yml | 99 +++++++++++++++++++++++++ shared/management/http/api/types.gen.go | 48 ++++++++++++ shared/management/proto/generate.sh | 2 +- shared/signal/proto/generate.sh | 2 +- 8 files changed, 154 insertions(+), 7 deletions(-) diff --git a/client/proto/generate.sh b/client/proto/generate.sh index cea8ae912..d73367d12 100755 --- a/client/proto/generate.sh +++ b/client/proto/generate.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e if ! which realpath >/dev/null 2>&1; then diff --git a/encryption/testprotos/generate.sh b/encryption/testprotos/generate.sh index 0ce6ebdea..ffbc481d6 100755 --- a/encryption/testprotos/generate.sh +++ b/encryption/testprotos/generate.sh @@ -1,2 +1,2 @@ -#!/bin/bash -protoc -I testprotos/ testprotos/testproto.proto --go_out=. \ No newline at end of file +#!/usr/bin/env bash +protoc -I testprotos/ testprotos/testproto.proto --go_out=. diff --git a/flow/proto/generate.sh b/flow/proto/generate.sh index 6bbf78e61..a031245fd 100755 --- a/flow/proto/generate.sh +++ b/flow/proto/generate.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e if ! which realpath > /dev/null 2>&1 diff --git a/shared/management/http/api/generate.sh b/shared/management/http/api/generate.sh index ba29a6905..8f563e99a 100755 --- a/shared/management/http/api/generate.sh +++ b/shared/management/http/api/generate.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e if ! which realpath > /dev/null 2>&1 diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index a0549b077..5ad682c6b 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -6443,6 +6443,44 @@ components: - enable_prompt_collection - redact_pii - access_log_retention_days + AgentNetworkManagedProxy: + type: object + description: A NetBird-managed Agent Network gateway deployment. + properties: + id: + type: string + description: Managed proxy deployment ID. + example: "d1m3kebd9pcs0c1pnu7g" + state: + type: string + description: Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure. + enum: [ "provisioning", "ready", "failed" ] + example: "ready" + endpoint: + type: string + description: The account's gateway hostname. + example: "brave-otter.gateway.netbird.io" + region: + type: string + description: Region of the cluster hosting the deployment. + example: "us-east" + message: + type: string + description: Failure detail reported by the rollout. Only set when state is `failed`. + required: + - id + - state + - endpoint + AgentNetworkManagedProxyConflict: + type: object + description: Conflict body returned when the account already has an Agent Network endpoint that managed provisioning does not own, naming that endpoint. + properties: + endpoint: + type: string + description: The Agent Network endpoint already assigned to the account. + example: "llm.example.com" + required: + - endpoint AgentNetworkBudgetRule: type: object description: 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. @@ -13531,6 +13569,67 @@ paths: "$ref": "#/components/responses/not_found" '500': "$ref": "#/components/responses/internal_error" + /api/integrations/agent-network/managed-proxy: + post: + summary: Provision a managed Agent Network gateway + description: Starts provisioning of a NetBird-managed Agent Network gateway for the account, allocating its endpoint under the managed zone on the first call. Idempotent — answers 202 when this call started (or, after a failure, restarted) provisioning and 200 when a deployment already exists, reporting current state either way. Returns 409 when the account already has an Agent Network endpoint that managed provisioning does not own, and 503 when endpoint allocation is temporarily exhausted. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A managed gateway deployment already exists; reports its current state. + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkManagedProxy' + '202': + description: Provisioning started, or restarted after a reported failure + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkManagedProxy' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '409': + description: The account already has an Agent Network endpoint not owned by managed provisioning + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkManagedProxyConflict' + '500': + "$ref": "#/components/responses/internal_error" + '503': + description: Endpoint allocation is temporarily exhausted; retry later + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + summary: Retrieve managed Agent Network gateway status + description: Reports the account's managed gateway deployment and its derived state. Returns 404 when the account has no managed deployment. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: The account's managed gateway deployment + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkManagedProxy' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" /api/agent-network/access-logs: get: summary: List Agent Network access logs diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index d6bebf134..b5a7a80ac 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -77,6 +77,27 @@ func (e AgentNetworkConsumptionDimensionKind) Valid() bool { } } +// Defines values for AgentNetworkManagedProxyState. +const ( + AgentNetworkManagedProxyStateFailed AgentNetworkManagedProxyState = "failed" + AgentNetworkManagedProxyStateProvisioning AgentNetworkManagedProxyState = "provisioning" + AgentNetworkManagedProxyStateReady AgentNetworkManagedProxyState = "ready" +) + +// Valid indicates whether the value is a known member of the AgentNetworkManagedProxyState enum. +func (e AgentNetworkManagedProxyState) Valid() bool { + switch e { + case AgentNetworkManagedProxyStateFailed: + return true + case AgentNetworkManagedProxyStateProvisioning: + return true + case AgentNetworkManagedProxyStateReady: + return true + default: + return false + } +} + // Defines values for CreateAzureIntegrationRequestHost. const ( CreateAzureIntegrationRequestHostMicrosoftCom CreateAzureIntegrationRequestHost = "microsoft.com" @@ -2224,6 +2245,33 @@ type AgentNetworkGuardrailRequest struct { Name string `json:"name"` } +// AgentNetworkManagedProxy A NetBird-managed Agent Network gateway deployment. +type AgentNetworkManagedProxy struct { + // Endpoint The account's gateway hostname. + Endpoint string `json:"endpoint"` + + // Id Managed proxy deployment ID. + Id string `json:"id"` + + // Message Failure detail reported by the rollout. Only set when state is `failed`. + Message *string `json:"message,omitempty"` + + // Region Region of the cluster hosting the deployment. + Region *string `json:"region,omitempty"` + + // State Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure. + State AgentNetworkManagedProxyState `json:"state"` +} + +// AgentNetworkManagedProxyState Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure. +type AgentNetworkManagedProxyState string + +// AgentNetworkManagedProxyConflict Conflict body returned when the account already has an Agent Network endpoint that managed provisioning does not own, naming that endpoint. +type AgentNetworkManagedProxyConflict struct { + // Endpoint The Agent Network endpoint already assigned to the account. + Endpoint string `json:"endpoint"` +} + // AgentNetworkModelDiscoveryRequest defines model for AgentNetworkModelDiscoveryRequest. type AgentNetworkModelDiscoveryRequest struct { // ApiKey Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id. diff --git a/shared/management/proto/generate.sh b/shared/management/proto/generate.sh index 7cb0f75a5..2915b7f0c 100755 --- a/shared/management/proto/generate.sh +++ b/shared/management/proto/generate.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e if ! which realpath > /dev/null 2>&1 diff --git a/shared/signal/proto/generate.sh b/shared/signal/proto/generate.sh index 720a5ff66..718eae152 100755 --- a/shared/signal/proto/generate.sh +++ b/shared/signal/proto/generate.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e if ! which realpath > /dev/null 2>&1 From 15c0a2903db2f082acf53a39bc671e3644a09b06 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 7 Sep 2026 15:50:28 +0200 Subject: [PATCH 15/63] [client] Return the context error when the SSH handshake fails with it (#7426) * [client] Return the context error when the SSH handshake fails on a context deadline The handshake mapped the context deadline onto the socket but returned the raw socket error. Which error surfaces depends on a race between the x/crypto ssh readLoop and kexLoop goroutines: the kexLoop write fails with i/o timeout and closes the conn, and the readLoop then reports use of closed network connection. Callers checking errors.Is(err, context.DeadlineExceeded) never matched, and TestSSHClient_ContextCancellation flaked on the FreeBSD job. Handshake now wraps the context error when the context is done or its deadline has passed. The deadline comparison is needed because the socket deadline and the context timer fire independently, so ctx.Err() can still be nil when the deadline-triggered socket error arrives. * [client] Close the silent test server conn without racing t.Cleanup The accept goroutine registered the conn close via t.Cleanup, which can run after the test's cleanup list has already been drained, leaving the accepted connection open. The goroutine now holds the conn until a cleanup-closed channel signals the end of the test and closes it on the way out. * [client] Bind the SSH handshake to the context instead of a socket deadline Mapping only the context deadline onto the socket left context cancellation unobserved: an in-flight handshake kept running until the deadline, and the error classification had to guess whether a raw socket error was caused by the deadline. Closing the conn from context.AfterFunc covers both deadline and cancellation, and ctx.Err() is already set by the time the close-induced error surfaces, so the time-based DeadlineExceeded attribution is no longer needed. The stop() result guards the window between a successful handshake and the AfterFunc firing so a closed conn is never handed back as a client. --- client/ssh/handshake.go | 33 +++++++------ client/ssh/handshake_test.go | 90 ++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 client/ssh/handshake_test.go diff --git a/client/ssh/handshake.go b/client/ssh/handshake.go index e78a806be..a718748df 100644 --- a/client/ssh/handshake.go +++ b/client/ssh/handshake.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net" - "time" log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" @@ -13,26 +12,23 @@ import ( // Handshake runs the SSH client handshake on an already dialed conn and // returns the resulting client. Dialing bounds only the TCP establishment; -// without a deadline on the socket a peer that accepts and then goes silent -// blocks the handshake forever, so the context deadline is applied to conn -// for the duration of the handshake. conn is closed on any error. +// a peer that accepts and then goes silent would block the handshake forever, +// so conn is closed as soon as ctx is done, which unblocks the handshake and +// surfaces the context error. conn is closed on any error. func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { - if deadline, ok := ctx.Deadline(); ok { - if err := conn.SetDeadline(deadline); err != nil { - closeHandshake(conn, "conn after deadline error") - return nil, fmt.Errorf("set handshake deadline: %w", err) - } - } + stop := context.AfterFunc(ctx, func() { closeHandshake(conn, "conn on context done") }) sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) if err != nil { - closeHandshake(conn, "conn after handshake error") - return nil, fmt.Errorf("ssh handshake: %w", err) + if stop() { + closeHandshake(conn, "conn after handshake error") + } + return nil, handshakeError(ctx, err) } - if err := conn.SetDeadline(time.Time{}); err != nil { - closeHandshake(sshConn, "ssh conn after deadline clear error") - return nil, fmt.Errorf("clear handshake deadline: %w", err) + if !stop() { + closeHandshake(sshConn, "ssh conn after context done") + return nil, fmt.Errorf("ssh handshake: %w", ctx.Err()) } return ssh.NewClient(sshConn, chans, reqs), nil @@ -43,3 +39,10 @@ func closeHandshake(c io.Closer, label string) { log.Debugf("ssh: close %s: %v", label, err) } } + +func handshakeError(ctx context.Context, err error) error { + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("ssh handshake: %w: %w", ctxErr, err) + } + return fmt.Errorf("ssh handshake: %w", err) +} diff --git a/client/ssh/handshake_test.go b/client/ssh/handshake_test.go new file mode 100644 index 000000000..77a6f916b --- /dev/null +++ b/client/ssh/handshake_test.go @@ -0,0 +1,90 @@ +package ssh + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +func TestHandshake_ContextDeadlineWrapped(t *testing.T) { + conn := dialSilentServer(t) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig()) + require.Error(t, err) + require.True(t, errors.Is(err, context.DeadlineExceeded), "expected context.DeadlineExceeded, got: %v", err) +} + +func TestHandshake_ContextCancelUnblocks(t *testing.T) { + conn := dialSilentServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + time.AfterFunc(50*time.Millisecond, cancel) + + errCh := make(chan error, 1) + go func() { + _, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig()) + errCh <- err + }() + + select { + case err := <-errCh: + require.Error(t, err) + require.True(t, errors.Is(err, context.Canceled), "expected context.Canceled, got: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("handshake did not return after context cancellation") + } +} + +func TestHandshake_NonContextErrorNotWrapped(t *testing.T) { + conn := dialSilentServer(t) + require.NoError(t, conn.Close()) + + _, err := Handshake(context.Background(), conn, conn.RemoteAddr().String(), testClientConfig()) + require.Error(t, err) + require.False(t, errors.Is(err, context.Canceled)) + require.False(t, errors.Is(err, context.DeadlineExceeded)) +} + +func testClientConfig() *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: "test", + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } +} + +// dialSilentServer returns a client conn to a server that accepts and never +// sends anything, so the SSH handshake blocks until the context is done. +func dialSilentServer(t *testing.T) net.Conn { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + done := make(chan struct{}) + t.Cleanup(func() { close(done) }) + + go func() { + c, err := listener.Accept() + if err != nil { + return + } + defer func() { _ = c.Close() }() + <-done + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return conn +} From e14006ddc14657320cc497b3c15bc89cd9d8a216 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:53:07 +0200 Subject: [PATCH 16/63] =?UTF-8?q?[client]=20mobile=20MDM=20bridge=20?= =?UTF-8?q?=E2=80=94=20iOS=20+=20Android=20setMDMPolicyFetcher=20entrypoin?= =?UTF-8?q?t=20(#6435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MDM Android mobile wiring * Removes dead code * Removes static vars * Now we need to apply MDM in the GetConfig * You now need to explicitly call these around * Adds iOS wiring * Resolve merge conflicts from main - login.go: keep both new imports (mdm + nbnet + server) - ios/NetBirdSDK/client.go: additive struct-field merge (mdmLoader + stateMu/connectClient/config) - setconfig_mdm_test.go: adopt new withMDMPolicy(t, s, policy) signature; fix stray old-signature call in TestSetConfig_MDMAllow_ManagementURLPortNormalized * Convey MDM overlay config to Debug Bundle output Aligns to other clients OSes behavior * Solved conflict in client.go * Fixup helper withMDMPolicy -> configWithMDM * Fixup after merge * Resolve merge conflicts * [client] Move MDM enforcement logic into a shared Go layer (#7319) The mobile bridges only carried the policy fetcher, leaving every enforcement decision to the native apps: the desktop derived its UI restrictions in the Wails service layer, the daemon kept the conflict machinery in the server package, and both mobile bridges duplicated the JSON fetch adapter. Anything the native side had to reimplement was a place for iOS and Android to drift apart. Enforcement now lives in client/mdm and is consumed identically by all three platforms: - conflicts.go holds the value-aware conflict checks lifted out of the daemon, so the same normalization (canonical URLs, PSK sentinel echo) applies wherever a config change is validated. - restrictions.go derives the UI enforcement snapshot from a policy and renders it in the JSON shape the desktop frontend already consumes. The service-layer types become aliases, keeping one source of truth. - jsonloader.go replaces the adapter that was copy-pasted into both bridges. - changedetector.go moves change detection off the native side: the caller forwards the OS notification and asks whether the managed configuration actually changed, instead of diffing dictionaries itself. The mobile bridges gain the enforcement the daemon already had. The Preferences getters resolve managed keys from the policy, so a naive UI shows the enforced value; Commit rejects a staged change that diverges from a managed key; NewAuth resolves the managed management URL before persisting the config and overlays the policy on it, so a login can no longer run against a URL the policy forbids. Android's profile mutations fail closed when disableProfiles is set. NewAuth takes the fetcher as a required argument rather than keeping a policy-blind overload: the apps consume this code as a submodule, so a compile error at the bump is the point. The mobile PSK getter is replaced by a presence check — the key has no reason to cross the bridge, and not returning it means the native side needs no redaction sentinel of its own. * [client] Resolve the main merge conflicts in the MDM integration The merge commit was recorded with the conflict markers still in the tree. Resolve them so the branch builds again: - client/ios/NetBirdSDK: keep both the mdm and mobile imports, and keep the mdmLoader/mdmDetector fields next to main's stateMu documentation. - client/server/mdm.go: drop the conflict helpers main added locally, they already live in the client/mdm package on this branch, and keep the new checks main introduced (allowRemoteJobs, enableLocalMetrics, localMetricsAddress) as calls into the package-level helpers. - client/mdm/conflicts.go: add ConflictStringPtr, the presence-aware string check main needs for the optional localMetricsAddress field. - Port the two tests main added over the per-Server loader helper and the configWithMDM helper, both of which replaced the package-level policy injection this branch removed. * [client] Reject explicit empty PSK when MDM enforces a pre-shared key The SetConfig, Login and mobile Commit conflict checks collapsed the PSK to a plain string, so an explicit empty value was indistinguishable from an unset field and slipped past the MDM gate, clearing the persisted key. Carry the optional field as a pointer through ConflictStringPtr, treating only the redaction sentinel as a no-op echo. ConflictString had no other callers and is removed. * [client] Apply MDM overlay on the preloaded iOS config in Run Run only overlaid the MDM policy when the config was loaded from file, so the tvOS path fed by SetConfigFromJSON started with unmanaged settings. Apply the overlay after the config source is selected, as the other resolution sites already do. * [client] Gate non-active profile logout behind the MDM profiles switch The mobile ProfileManager let LogoutProfile clear credentials of any profile even when disableProfiles was enforced. Follow the daemon's validateProfileLogout semantics: logging out of the active profile is a plain logout and stays allowed, logging out of any other profile is profile management and is rejected under the policy. * [client] Resolve the managed management URL through the MDM overlay on mobile NewAuth on Android and iOS replaced the caller URL with the raw policy value before persisting, so a malformed managed URL failed config validation and blocked the login instead of being skipped with a warning like the overlay does. Preferences.GetManagementURL likewise echoed the raw policy string to the native UI even when the overlay had rejected it. Follow the daemon: persist the caller URL, overlay the policy on the resolved config, and report the overlaid ManagementURL as the effective value. * [client] Clean up MDM review leftovers Drop the unused ChangeDetector.Current, point the stale LoadPolicy comment references at Loader.Load, and move the profileEmail godoc back above its function. * [client] Check remote jobs and local metrics keys in the mobile MDM conflict gate MDMConflicts skipped allowRemoteJobs, enableLocalMetrics and localMetricsAddress even though the overlay applies all three and the daemon gate already checks them, so a mobile Commit could persist values diverging from the enforced policy. Align the list with the daemon. * [client] Silence the deprecated PreSharedKey lint in the login conflict test The legacy LoginRequest.PreSharedKey field is deliberately exercised by the test, matching the nolint already carried by the production path. * [client] Publish the mobile MDM loader and detector atomically SetMDMPolicyFetcher wrote the loader and change detector as two plain fields that Run, the OS-change callback and the restrictions getter read from other threads without synchronization. Hold both behind a single atomic pointer so a registration is published as one unit and readers always observe a matching loader and detector pair; Preferences gets the same treatment for its loader. Exported signatures are unchanged. * [client] Report the MDM-overlaid remote jobs value from mobile Preferences GetRemoteJobsAllowed returned the staged or persisted value even when the policy manages allowRemoteJobs, so the native settings UI could show a value the Commit gate would reject. Resolve it through the overlay like GetManagementURL does. * [client] Stop persisting the MDM-overlaid config after mobile logins NewAuth already writes the config through UpdateOrCreateConfig before the MDM policy is overlaid, and the login itself never mutates the Config. The post-login WriteOutConfig calls therefore only rewrote the same file with the enforced ManagementURL and PreSharedKey in it, so a removed or changed policy kept acting through the persisted values. * [client] Document that the MDM overlay on Config is not reversible ApplyMDMPolicy promised that an empty Policy clears a prior overlay, but applyMDMPolicy only resets the enforcement metadata and the runtime-only upload URL; the enforced ManagementURL, PreSharedKey and flags stay. Every lifecycle owner resolves the base Config again before applying, so state that contract instead of the reversibility that was never implemented. * [client] Re-resolve the tvOS preloaded config before every MDM overlay The iOS Client kept the config parsed from SetConfigFromJSON and applied the MDM overlay onto that same instance on every Run, IsLoginRequired and DebugBundle, so a key removed from the policy stayed enforced. Store the JSON instead and parse it per load through one loadConfig path. Auth serialized the overlaid config from GetConfigJSON, which tvOS then persisted to UserDefaults and fed back as the preload. Keep the resolved config as the base, run the login on a JSON round-trip copy with the overlay, and return the base from GetConfigJSON. * [client] Serve the MDM-managed management URL without touching the config file on mobile Preferences.GetManagementURL resolved a managed URL by reading and overlaying the persisted config, so a corrupt file or the tvOS sandbox turned an enforced URL into a read error. Return the canonical managed value directly, the same string BuildRestrictions already hands to the UI, and only fall back to the staged or persisted value when MDM does not manage the key. NewAuth validated the caller-supplied management URL before the overlay ran, so a malformed or echoed value blocked or persisted under an MDM policy that already dictates the URL. Ignore the caller value while the key is managed; the login runs against the overlay either way. * [client] Align the MDM loader docs with the fetcher precedence and make disableAdvancedView a tristate NewLoader, PolicyFetcher and the darwin/windows loadPlatform docs claimed the fetcher is unused on desktop, while every loader returns its values when one is injected. That precedence is the seam the server tests rely on across platforms, so the docs now describe it; production desktop callers still pass nil and keep the registry / plist authoritative. Fields.DisableAdvancedView collapsed "managed and false" into the same JSON as "not managed", unlike AllowServerSSH and the daemon's optional proto field. Carry it as a *bool so the UIs can tell the two apart; the desktop reflect loop skips pointer fields already, and the mobile decoders treat null as not managed. * [client] Clean up MDM review nits - ResolveConflicts treats a managed key whose ConflictCheck has no Check as a conflict instead of dereferencing nil. - Ticker.Run and ChangeDetector.Changed share policyChanged so the diff semantics and the log line cannot drift apart. - TestLoader_NilFetcherReturnsEmpty skips on windows/darwin, where a nil fetcher reads the real registry / plist. - The profilemanager test loader checks GetInt before GetBool so integer keys survive the round trip, and the PSK tests use the exported redaction sentinel. * [client] Fix int policy values coercing to bool in the MDM test helper withMDMPolicy rebuilt the policy map by trying GetString, then GetBool, then GetInt. Policy.GetBool accepts native ints (non-zero means true), so an int-valued key such as wireguardPort round-tripped through the helper as the bool true and GetInt was never reached. Try GetInt before GetBool, as the profilemanager helper already does; GetInt does not coerce bools, so booleans still fall through to GetBool. No test sets an int key today, so this was latent: the first test to exercise the wireguardPort conflict gate would have seen ConflictInt64 report a conflict for every value, including a matching one. --------- Co-authored-by: Zoltan Papp --- client/android/client.go | 12 + client/android/client_mdm.go | 52 ++++ client/android/login.go | 33 +-- client/android/login_test.go | 6 +- client/android/mdm.go | 19 ++ client/android/preferences.go | 68 +++++- client/android/preferences_test.go | 25 +- client/android/profile_manager.go | 6 + client/cmd/login.go | 6 + client/cmd/up.go | 5 + client/embed/embed.go | 5 + client/internal/profilemanager/config.go | 34 ++- client/internal/profilemanager/config_mdm.go | 52 ++++ .../profilemanager/config_mdm_test.go | 209 ++++++++++------ client/ios/NetBirdSDK/client.go | 107 ++++----- client/ios/NetBirdSDK/login.go | 103 ++++---- client/ios/NetBirdSDK/mdm.go | 66 ++++++ client/ios/NetBirdSDK/preferences.go | 56 ++++- client/ios/NetBirdSDK/preferences_test.go | 25 +- client/ios/NetBirdSDK/profile_manager.go | 6 + client/mdm/changedetector.go | 34 +++ client/mdm/conflicts.go | 111 +++++++++ client/mdm/jsonloader.go | 34 +++ client/mdm/policy.go | 42 +++- client/mdm/policy_darwin.go | 15 +- client/mdm/policy_mobile.go | 19 +- client/mdm/policy_other.go | 20 +- client/mdm/policy_test.go | 14 +- client/mdm/policy_windows.go | 15 +- client/mdm/restrictions.go | 89 +++++++ client/mdm/ticker.go | 46 ++-- client/mdm/ticker_test.go | 67 +++--- client/mobile/profile_manager.go | 42 ++++ client/mobile/profile_manager_mdm_test.go | 83 +++++++ client/server/mdm.go | 223 +++--------------- client/server/server.go | 35 ++- client/server/setconfig_mdm_test.go | 142 ++++++++--- client/ui/autostart_default.go | 2 +- client/ui/services/settings.go | 38 +-- 39 files changed, 1375 insertions(+), 591 deletions(-) create mode 100644 client/android/client_mdm.go create mode 100644 client/android/mdm.go create mode 100644 client/internal/profilemanager/config_mdm.go create mode 100644 client/ios/NetBirdSDK/mdm.go create mode 100644 client/mdm/changedetector.go create mode 100644 client/mdm/conflicts.go create mode 100644 client/mdm/jsonloader.go create mode 100644 client/mdm/restrictions.go create mode 100644 client/mobile/profile_manager_mdm_test.go diff --git a/client/android/client.go b/client/android/client.go index 5bd0d1e10..e47a1c13d 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -9,6 +9,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "time" "golang.org/x/exp/maps" @@ -90,6 +91,14 @@ type Client struct { connectClient *internal.ConnectClient config *profilemanager.Config cacheDir string + + // mdmSource holds the per-Client MDM policy source and its change + // detector as one unit. Set by SetMDMPolicyFetcher (called from the + // Kotlin side). Each Run passes the loader to the resolved Config so + // applyMDMPolicy picks up the active overlay. Nil means "MDM + // enforcement off for this Client". + mdmSource atomic.Pointer[mdmSource] + // Identifies the running profile for the SSO login hint; see profile_state.go. cfgPath string @@ -178,6 +187,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid if err != nil { return err } + c.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -229,6 +239,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR if err != nil { return err } + c.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -327,6 +338,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym if err != nil { return "", fmt.Errorf("load config: %w", err) } + c.applyMDMOverlay(cfg) cacheDir = platformFiles.CacheDir() } diff --git a/client/android/client_mdm.go b/client/android/client_mdm.go new file mode 100644 index 000000000..d043b85d3 --- /dev/null +++ b/client/android/client_mdm.go @@ -0,0 +1,52 @@ +//go:build android + +package android + +import ( + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +type mdmSource struct { + loader *mdm.Loader + detector *mdm.ChangeDetector +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Client; passing nil disables MDM enforcement. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { + loader := loaderFor(p) + c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)}) +} + +// HasMDMPolicyChanged re-reads the managed configuration and reports whether +// it changed since the last observation; call it from the native OS-change +// notification and restart the engine only on true. +func (c *Client) HasMDMPolicyChanged() bool { + src := c.mdmSource.Load() + if src == nil { + return false + } + return src.detector.Changed() +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (c *Client) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON() +} + +func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { + loader := c.mdmLoader() + if cfg == nil || loader == nil { + return + } + cfg.ApplyMDMPolicy(loader.Load()) +} + +func (c *Client) mdmLoader() *mdm.Loader { + if src := c.mdmSource.Load(); src != nil { + return src.loader + } + return nil +} diff --git a/client/android/login.go b/client/android/login.go index 3742e01a5..155c6eadd 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/mdm" "github.com/netbirdio/netbird/client/mobile" "github.com/netbirdio/netbird/client/system" ) @@ -46,16 +47,24 @@ type Auth struct { // an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from // the persisted config, because the identity it registered is not the one it runs with — the // management stream rejects it with "no peer auth method provided". -func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { - inputCfg := profilemanager.ConfigInput{ - ConfigPath: cfgPath, - ManagementURL: mgmURL, +// +// Auth is constructed under the active MDM policy: the policy is overlaid on +// the resolved config so the login runs against the enforced values, while +// the persisted config keeps the caller-supplied ones; a caller-supplied +// management URL is ignored while MDM manages that key. A nil fetcher +// disables MDM enforcement. +func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { + policy := loaderFor(fetcher).Load() + inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath} + if _, managed := policy.GetString(mdm.KeyManagementURL); !managed { + inputCfg.ManagementURL = mgmURL } cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg) if err != nil { return nil, err } + cfg.ApplyMDMPolicy(policy) return &Auth{ ctx: context.Background(), @@ -75,9 +84,7 @@ func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPa } } -// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info. -// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO -// is not supported and returns false without saving the configuration. For other errors return false. +// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth. func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) { go func() { sso, err := a.saveConfigIfSSOSupported() @@ -101,15 +108,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) { return false, fmt.Errorf("failed to check SSO support: %v", err) } - if !supportsSSO { - return false, nil - } - - err = profilemanager.WriteOutConfig(a.cfgPath, a.config) - return true, err + return supportsSSO, nil } -// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key. +// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth. func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) { go func() { err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName) @@ -134,8 +136,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string if err != nil { return fmt.Errorf("login failed: %v", err) } - - return profilemanager.WriteOutConfig(a.cfgPath, a.config) + return nil } // Login try register the client on the server diff --git a/client/android/login_test.go b/client/android/login_test.go index b04790f6b..130a846fc 100644 --- a/client/android/login_test.go +++ b/client/android/login_test.go @@ -16,7 +16,7 @@ import ( func TestNewAuth_ReusesPersistedIdentity(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "config.json") - first, err := NewAuth(cfgPath, "https://api.example.com:443") + first, err := NewAuth(cfgPath, "https://api.example.com:443", nil) if err != nil { t.Fatalf("first NewAuth: %v", err) } @@ -24,7 +24,7 @@ func TestNewAuth_ReusesPersistedIdentity(t *testing.T) { t.Fatal("first NewAuth produced no private key") } - second, err := NewAuth(cfgPath, "https://api.example.com:443") + second, err := NewAuth(cfgPath, "https://api.example.com:443", nil) if err != nil { t.Fatalf("second NewAuth: %v", err) } @@ -38,7 +38,7 @@ func TestNewAuth_ReusesPersistedIdentity(t *testing.T) { func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "config.json") - auth, err := NewAuth(cfgPath, "https://api.example.com:443") + auth, err := NewAuth(cfgPath, "https://api.example.com:443", nil) if err != nil { t.Fatalf("NewAuth: %v", err) } diff --git a/client/android/mdm.go b/client/android/mdm.go new file mode 100644 index 000000000..617d8f7cb --- /dev/null +++ b/client/android/mdm.go @@ -0,0 +1,19 @@ +package android + +import ( + "github.com/netbirdio/netbird/client/mdm" +) + +// PolicyFetcher is implemented by the native layer to return the current +// managed configuration as a JSON-encoded object string; "" means no MDM +// source is present. +type PolicyFetcher interface { + FetchJSON() string +} + +func loaderFor(p PolicyFetcher) *mdm.Loader { + if p == nil { + return mdm.NewJSONLoader(nil) + } + return mdm.NewJSONLoader(p.FetchJSON) +} diff --git a/client/android/preferences.go b/client/android/preferences.go index d90365518..5ce31026c 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -1,12 +1,16 @@ package android import ( + "sync/atomic" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" ) // Preferences exports a subset of the internal config for gomobile type Preferences struct { configInput profilemanager.ConfigInput + mdmLoader atomic.Pointer[mdm.Loader] } // NewPreferences creates a new Preferences instance @@ -14,11 +18,30 @@ func NewPreferences(configPath string) *Preferences { ci := profilemanager.ConfigInput{ ConfigPath: configPath, } - return &Preferences{ci} + return &Preferences{configInput: ci} +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Preferences instance; passing nil disables MDM enforcement. +func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) { + p.mdmLoader.Store(loaderFor(f)) +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (p *Preferences) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(p.policy()).JSON() +} + +func (p *Preferences) policy() *mdm.Policy { + return p.mdmLoader.Load().Load() } // GetManagementURL reads URL from config file func (p *Preferences) GetManagementURL() (string, error) { + if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok { + return mdm.CanonicalURL(v), nil + } if p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -27,7 +50,7 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - return cfg.ManagementURL.String(), err + return cfg.ManagementURL.String(), nil } // SetManagementURL stores the given URL and waits for commit @@ -53,17 +76,21 @@ func (p *Preferences) SetAdminURL(url string) { p.configInput.AdminURL = url } -// GetPreSharedKey reads pre-shared key from config file -func (p *Preferences) GetPreSharedKey() (string, error) { +// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or +// enforced by MDM; the key itself is never handed to the native layer. +func (p *Preferences) HasPreSharedKey() (bool, error) { + if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok { + return true, nil + } if p.configInput.PreSharedKey != nil { - return *p.configInput.PreSharedKey, nil + return *p.configInput.PreSharedKey != "", nil } cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) if err != nil { - return "", err + return false, err } - return cfg.PreSharedKey, err + return cfg.PreSharedKey != "", nil } // SetPreSharedKey stores the given key and waits for commit @@ -78,6 +105,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) { // GetRosenpassEnabled reads Rosenpass enabled status from config file func (p *Preferences) GetRosenpassEnabled() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok { + return v, nil + } if p.configInput.RosenpassEnabled != nil { return *p.configInput.RosenpassEnabled, nil } @@ -96,6 +126,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) { // GetRosenpassPermissive reads Rosenpass permissive setting from config file func (p *Preferences) GetRosenpassPermissive() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok { + return v, nil + } if p.configInput.RosenpassPermissive != nil { return *p.configInput.RosenpassPermissive, nil } @@ -109,6 +142,9 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { // GetDisableClientRoutes reads disable client routes setting from config file func (p *Preferences) GetDisableClientRoutes() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyDisableClientRoutes); ok { + return v, nil + } if p.configInput.DisableClientRoutes != nil { return *p.configInput.DisableClientRoutes, nil } @@ -127,6 +163,9 @@ func (p *Preferences) SetDisableClientRoutes(disable bool) { // GetDisableServerRoutes reads disable server routes setting from config file func (p *Preferences) GetDisableServerRoutes() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyDisableServerRoutes); ok { + return v, nil + } if p.configInput.DisableServerRoutes != nil { return *p.configInput.DisableServerRoutes, nil } @@ -181,6 +220,9 @@ func (p *Preferences) SetDisableFirewall(disable bool) { // GetServerSSHAllowed reads server SSH allowed setting from config file func (p *Preferences) GetServerSSHAllowed() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyAllowServerSSH); ok { + return v, nil + } if p.configInput.ServerSSHAllowed != nil { return *p.configInput.ServerSSHAllowed, nil } @@ -291,6 +333,9 @@ func (p *Preferences) SetEnableSSHRemotePortForwarding(enabled bool) { // GetBlockInbound reads block inbound setting from config file func (p *Preferences) GetBlockInbound() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyBlockInbound); ok { + return v, nil + } if p.configInput.BlockInbound != nil { return *p.configInput.BlockInbound, nil } @@ -327,7 +372,8 @@ func (p *Preferences) SetDisableIPv6(disable bool) { // GetRemoteJobsAllowed reads the remote jobs opt-in from config file func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { - if p.configInput.RemoteJobsAllowed != nil { + policy := p.policy() + if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil { return *p.configInput.RemoteJobsAllowed, nil } @@ -335,10 +381,11 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { if err != nil { return false, err } + cfg.ApplyMDMPolicy(policy) if cfg.RemoteJobsAllowed == nil { return false, nil } - return *cfg.RemoteJobsAllowed, err + return *cfg.RemoteJobsAllowed, nil } // SetRemoteJobsAllowed stores the given value and waits for commit @@ -348,6 +395,9 @@ func (p *Preferences) SetRemoteJobsAllowed(allowed bool) { // Commit writes out the changes to the config file func (p *Preferences) Commit() error { + if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil { + return err + } _, err := profilemanager.UpdateOrCreateConfig(p.configInput) return err } diff --git a/client/android/preferences_test.go b/client/android/preferences_test.go index 2bbccef86..d9f5b1918 100644 --- a/client/android/preferences_test.go +++ b/client/android/preferences_test.go @@ -28,14 +28,13 @@ func TestPreferences_DefaultValues(t *testing.T) { t.Errorf("invalid default management url: %s", defaultVar) } - var preSharedKey string - preSharedKey, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read default preshared key: %s", err) + t.Fatalf("failed to read default preshared key presence: %s", err) } - if preSharedKey != "" { - t.Errorf("invalid preshared key: %s", preSharedKey) + if hasPSK { + t.Errorf("unexpected preshared key presence on fresh config") } } @@ -65,13 +64,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) { } p.SetPreSharedKey(exampleString) - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != exampleString { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after staging one") } } @@ -109,12 +108,12 @@ func TestPreferences_Commit(t *testing.T) { t.Errorf("unexpected management url: %s", resp) } - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != examplePresharedKey { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after commit") } } diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 557c837a7..4bc60c453 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -54,6 +54,12 @@ func NewProfileManager(configDir string) *ProfileManager { return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)} } +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this ProfileManager; passing nil disables MDM enforcement. +func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) { + pm.impl.SetMDMLoader(loaderFor(f)) +} + // ListProfiles returns all available profiles, including the default profile, // with their active status set. func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { diff --git a/client/cmd/login.go b/client/cmd/login.go index 4e08334eb..11867be09 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" @@ -330,6 +331,11 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, if err != nil { return fmt.Errorf("read config file %s: %v", configFilePath, err) } + // CLI standalone login: profilemanager no longer auto-applies MDM, + // so layer in the OS-native policy here. Desktop builds construct + // a Loader with no fetcher — the build-tagged loadPlatform reads + // the registry/plist directly. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) // Mirror runInForegroundMode: recover residual state (DNS, firewall, // ssh config, legacy routing) from a previous unclean shutdown and diff --git a/client/cmd/up.go b/client/cmd/up.go index 2e53224df..f5fac9749 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -21,6 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" @@ -234,6 +235,10 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr if err != nil { return fmt.Errorf("get config file: %v", err) } + // CLI foreground path runs without the daemon Server: layer in the + // active MDM policy explicitly so a forced ManagementURL / PSK / + // other managed key actually takes effect on this run. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) _, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath) diff --git a/client/embed/embed.go b/client/embed/embed.go index 5a3d11f24..5a3d540ec 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -21,6 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" @@ -229,6 +230,10 @@ func New(opts Options) (*Client, error) { if err != nil { return nil, fmt.Errorf("create config: %w", err) } + // Embedded path runs without the daemon Server: apply the active + // MDM policy explicitly so a forced ManagementURL / PSK / other + // managed key takes effect on this embedded engine instance. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) if opts.PrivateKey != "" { config.PrivateKey = opts.PrivateKey diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 10c1758d1..412f81b5c 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{ "Tailscale", "tailscale", "docker", "veth", "br-", "lo", } -// loadMDMPolicy is the package-level indirection used by apply() to read the -// active MDM policy. Tests override this to inject a fake policy. -var loadMDMPolicy = mdm.LoadPolicy - // ConfigInput carries configuration changes to the client type ConfigInput struct { ManagementURL string @@ -202,14 +198,26 @@ type Config struct { MTU uint16 - // policy is the MDM policy that produced the currently-set values for - // any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply() - // and reset on every apply() invocation. Never persisted to disk. - // Callers query enforcement state via Policy() and the mdm.Policy API - // (HasKey, ManagedKeys, IsEmpty). + // policy is the MDM policy that produced the currently-set values + // for any MDM-enforced fields. Set by ApplyMDMPolicy on every + // invocation. Never persisted to disk. Callers query enforcement + // state via Policy() and the mdm.Policy API (HasKey, ManagedKeys, + // IsEmpty). policy *mdm.Policy `json:"-"` } +// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current +// Config values and records it as Policy(). The overlay is not reversible: +// an empty Policy only clears the enforcement metadata, so resolve the base +// Config again (from disk or JSON) before applying a changed policy, the way +// the lifecycle owners do on every load. +func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) { + if config == nil { + return + } + config.applyMDMPolicy(policy) +} + // Policy returns the MDM policy applied to this Config. Returns a non-nil // empty Policy when MDM enforcement is inactive; callers can always invoke // HasKey / ManagedKeys / IsEmpty without a nil check. @@ -712,9 +720,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - // MDM is the last override layer: any key present in the policy - // supersedes defaults, on-disk config, env vars and CLI input. - config.applyMDMPolicy(loadMDMPolicy()) + // Initialise the MDM overlay to "no enforcement" so Config.Policy() + // never returns a stale or nil policy on a freshly applied Config. + // Lifecycle owners that want to enforce a real MDM policy invoke + // Config.ApplyMDMPolicy(loader.Load()) after this returns. + config.applyMDMPolicy(mdm.NewPolicy(nil)) return updated, nil } diff --git a/client/internal/profilemanager/config_mdm.go b/client/internal/profilemanager/config_mdm.go new file mode 100644 index 000000000..25b9f18f7 --- /dev/null +++ b/client/internal/profilemanager/config_mdm.go @@ -0,0 +1,52 @@ +package profilemanager + +import ( + "errors" + "fmt" + + "github.com/netbirdio/netbird/client/mdm" +) + +// ErrMDMManagedFields marks a config change rejected because it diverges from +// MDM-enforced values. +var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified") + +// MDMConflicts returns the names of MDM-managed keys whose requested value in +// the ConfigInput differs from the policy-enforced value; a field set to the +// enforced value is a no-op echo, not a conflict. +func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string { + pskGot := input.PreSharedKey + if isPreSharedKeyHidden(pskGot) { + pskGot = nil + } + var port *int64 + if input.WireguardPort != nil { + v := int64(*input.WireguardPort) + port = &v + } + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, port), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress), + }) +} + +// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the +// conflicting keys, or nil when the input does not fight the policy. +func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error { + conflicts := MDMConflicts(input, policy) + if len(conflicts) == 0 { + return nil + } + return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts) +} diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index f8dfddb33..716b7a553 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -10,24 +10,58 @@ import ( "github.com/netbirdio/netbird/client/mdm" ) -// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so -// apply() observes the supplied Policy. The original loader is restored at -// test cleanup. -func withMDMPolicy(t *testing.T, policy *mdm.Policy) { +// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy +// map. Test helper used to construct a Loader without touching the OS +// or any package-level state. +type fakeFetcher struct{ values map[string]any } + +func (f *fakeFetcher) Fetch() map[string]any { return f.values } + +// loaderFor builds an mdm.Loader whose loadPlatform returns the +// supplied Policy's underlying values. +func loaderFor(policy *mdm.Policy) *mdm.Loader { + if policy == nil || policy.IsEmpty() { + return mdm.NewLoader(&fakeFetcher{values: nil}) + } + values := make(map[string]any) + for _, k := range policy.ManagedKeys() { + if v, ok := policy.GetString(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetInt(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetBool(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetStringSlice(k); ok { + values[k] = v + } + } + return mdm.NewLoader(&fakeFetcher{values: values}) +} + +// configWithMDM is the test convenience that builds a Config via +// UpdateOrCreateConfig and overlays the supplied MDM policy on top — +// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay) +// where the Loader lives outside Config and the apply step is driven +// by the lifecycle owner. +func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config { t.Helper() - prev := loadMDMPolicy - loadMDMPolicy = func() *mdm.Policy { return policy } - t.Cleanup(func() { loadMDMPolicy = prev }) + cfg, err := UpdateOrCreateConfig(input) + require.NoError(t, err) + require.NotNil(t, cfg) + cfg.ApplyMDMPolicy(loaderFor(policy).Load()) + return cfg } func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(nil)) - - cfg, err := UpdateOrCreateConfig(ConfigInput{ + cfg := configWithMDM(t, ConfigInput{ ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) + }, mdm.NewPolicy(nil)) assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy") assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) @@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { func TestApply_MDMOnly_OverridesDefaults(t *testing.T) { const mdmURL = "https://corp.mdm.example.com:443" - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: mdmURL, mdm.KeyDisableClientRoutes: true, mdm.KeyBlockInbound: true, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.Equal(t, mdmURL, cfg.ManagementURL.String()) assert.True(t, cfg.DisableClientRoutes) assert.True(t, cfg.BlockInbound) @@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) { const mdmURL = "https://mdm.example.com:443" const cliURL = "https://cli.example.com:443" - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ - mdm.KeyManagementURL: mdmURL, - })) - - cfg, err := UpdateOrCreateConfig(ConfigInput{ + cfg := configWithMDM(t, ConfigInput{ ConfigPath: filepath.Join(t.TempDir(), "config.json"), ManagementURL: cliURL, - }) - require.NoError(t, err) - require.NotNil(t, cfg) + }, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: mdmURL, + })) // MDM wins over CLI-supplied management URL. assert.Equal(t, mdmURL, cfg.ManagementURL.String()) @@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) { } func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "not-a-url", })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - // Invalid MDM URL is logged and skipped: default URL stays in place // to keep the client functional. assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) @@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { tmp := filepath.Join(t.TempDir(), "config.json") // Seed without MDM. - withMDMPolicy(t, mdm.NewPolicy(nil)) - _, err := UpdateOrCreateConfig(ConfigInput{ + configWithMDM(t, ConfigInput{ ConfigPath: tmp, DisableClientRoutes: boolPtr(false), RosenpassEnabled: boolPtr(false), - }) - require.NoError(t, err) + }, mdm.NewPolicy(nil)) // Now enable MDM enforcement for these keys. - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + }, mdm.NewPolicy(map[string]any{ mdm.KeyDisableClientRoutes: true, mdm.KeyRosenpassEnabled: true, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true") assert.True(t, cfg.RosenpassEnabled) assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes)) @@ -134,22 +153,19 @@ func TestApply_MDMLocalMetrics(t *testing.T) { tmp := filepath.Join(t.TempDir(), "config.json") // Seed without MDM. - withMDMPolicy(t, mdm.NewPolicy(nil)) - _, err := UpdateOrCreateConfig(ConfigInput{ + configWithMDM(t, ConfigInput{ ConfigPath: tmp, LocalMetricsEnabled: boolPtr(false), - }) - require.NoError(t, err) + }, mdm.NewPolicy(nil)) - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + // Now enable MDM enforcement for these keys. + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + }, 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)) @@ -171,16 +187,12 @@ func TestApply_MDMLazyConnection(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyLazyConnection: c.raw, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.Equal(t, c.want, cfg.LazyConnection) assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection)) }) @@ -188,22 +200,83 @@ func TestApply_MDMLazyConnection(t *testing.T) { } func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { - const maskSentinel = "**********" + const maskSentinel = mdm.PreSharedKeyRedactedSentinel - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyPreSharedKey: maskSentinel, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - // Mask sentinel must not be persisted as the actual PSK. assert.NotEqual(t, maskSentinel, cfg.PreSharedKey) // Key still marked managed so user writes are still rejected. assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey)) } +func TestMDMConflicts_PreSharedKey(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + }) + empty := "" + sentinel := mdm.PreSharedKeyRedactedSentinel + same := "mdm-enforced-psk" + other := "user-psk" + + tests := []struct { + name string + psk *string + want []string + }{ + {name: "unset", psk: nil, want: nil}, + {name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}}, + {name: "sentinel echo", psk: &sentinel, want: nil}, + {name: "same value", psk: &same, want: nil}, + {name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy)) + }) + } +} + +func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyRemoteJobsAllowed: false, + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", + }) + sameAddr := "127.0.0.1:9999" + otherAddr := "0.0.0.0:9999" + emptyAddr := "" + + tests := []struct { + name string + input ConfigInput + want []string + }{ + {name: "unset", input: ConfigInput{}, want: nil}, + {name: "echo", input: ConfigInput{ + RemoteJobsAllowed: boolPtr(false), + LocalMetricsEnabled: boolPtr(true), + LocalMetricsAddress: &sameAddr, + }, want: nil}, + {name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}}, + {name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}}, + {name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}}, + {name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}}, + {name: "all divergent", input: ConfigInput{ + RemoteJobsAllowed: boolPtr(true), + LocalMetricsEnabled: boolPtr(false), + LocalMetricsAddress: &otherAddr, + }, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, MDMConflicts(tc.input, policy)) + }) + } +} + func boolPtr(b bool) *bool { return &b } diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index bbbb969c9..96c747ae4 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -88,9 +88,15 @@ type Client struct { // 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 + netMgr *netevents.Manager + preloadedConfigJSON atomic.Pointer[string] + + // mdmSource holds the per-Client MDM policy source and its change + // detector as one unit. Set by SetMDMPolicyFetcher (called from the + // Swift side at extension init). Each Run passes the loader to the + // resolved Config so applyMDMPolicy picks up the active overlay. Nil + // means "MDM enforcement off for this Client". + mdmSource atomic.Pointer[mdmSource] // 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 @@ -122,44 +128,44 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV } } -// SetConfigFromJSON loads config from a JSON string into memory. -// This is used on tvOS where file writes to App Group containers are blocked. -// When set, IsLoginRequired() and Run() will use this preloaded config instead of reading from file. +// SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS). func (c *Client) SetConfigFromJSON(jsonStr string) error { - cfg, err := profilemanager.ConfigFromJSON(jsonStr) - if err != nil { + if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil { log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err) return err } - c.preloadedConfig = cfg + c.preloadedConfigJSON.Store(&jsonStr) log.Infof("SetConfigFromJSON: config loaded successfully from JSON") return nil } +func (c *Client) loadConfig(input profilemanager.ConfigInput) (*profilemanager.Config, error) { + var cfg *profilemanager.Config + var err error + if preloaded := c.preloadedConfigJSON.Load(); preloaded != nil { + cfg, err = profilemanager.ConfigFromJSON(*preloaded) + } else { + cfg, err = profilemanager.DirectUpdateOrCreateConfig(input) + } + if err != nil { + return nil, err + } + c.applyMDMOverlay(cfg) + return cfg, nil +} + // Run start the internal client. It is a blocker function func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { exportEnvList(envList) log.Infof("Starting NetBird client") log.Debugf("Tunnel uses interface: %s", interfaceName) - var cfg *profilemanager.Config - var err error - - // Use preloaded config if available (tvOS where file writes are blocked) - if c.preloadedConfig != nil { - log.Infof("Run: using preloaded config from memory") - cfg = c.preloadedConfig - } else { - log.Infof("Run: loading config from file") - // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - StateFilePath: c.stateFile, - }) - if err != nil { - return err - } + cfg, err := c.loadConfig(profilemanager.ConfigInput{ + ConfigPath: c.cfgFile, + StateFilePath: c.stateFile, + }) + if err != nil { + return err } c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -274,19 +280,13 @@ func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, err // If the engine hasn't been started, load config so we can reach management. if cfg == nil { - if c.preloadedConfig != nil { - cfg = c.preloadedConfig - } else { - var err error - // Use DirectUpdateOrCreateConfig to avoid atomic file operations - // (temp file + rename) blocked by the tvOS sandbox. - cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - StateFilePath: c.stateFile, - }) - if err != nil { - return "", fmt.Errorf("load config: %w", err) - } + var err error + cfg, err = c.loadConfig(profilemanager.ConfigInput{ + ConfigPath: c.cfgFile, + StateFilePath: c.stateFile, + }) + if err != nil { + return "", fmt.Errorf("load config: %w", err) } } @@ -421,29 +421,9 @@ func (c *Client) IsLoginRequired() bool { ctx, cancel := context.WithCancel(ctxWithValues) defer cancel() - var cfg *profilemanager.Config - var err error - - // Use preloaded config if available (tvOS where file writes are blocked) - if c.preloadedConfig != nil { - log.Infof("IsLoginRequired: using preloaded config from memory") - cfg = c.preloadedConfig - } else { - log.Infof("IsLoginRequired: loading config from file") - // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - }) - if err != nil { - log.Errorf("IsLoginRequired: failed to load config: %v", err) - // If we can't load config, assume login is required - return true - } - } - - if cfg == nil { - log.Errorf("IsLoginRequired: config is nil") + cfg, err := c.loadConfig(profilemanager.ConfigInput{ConfigPath: c.cfgFile}) + if err != nil { + log.Errorf("IsLoginRequired: failed to load config: %v", err) return true } @@ -493,6 +473,7 @@ func (c *Client) LoginForMobile() string { log.Errorf("LoginForMobile: failed to load config: %v", err) return fmt.Sprintf("failed to load config: %v", err) } + c.applyMDMOverlay(cfg) oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "") if err != nil { diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index cf7aa6730..0dfff620e 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/mdm" "github.com/netbirdio/netbird/client/mobile" "github.com/netbirdio/netbird/client/system" ) @@ -39,14 +40,22 @@ type Auth struct { ctx context.Context cancel context.CancelFunc config *profilemanager.Config + base *profilemanager.Config + policy *mdm.Policy cfgPath string } -// NewAuth instantiate Auth struct and validate the management URL -func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { - inputCfg := profilemanager.ConfigInput{ - ConfigPath: cfgPath, - ManagementURL: mgmURL, +// NewAuth instantiate Auth struct and validate the management URL. +// Auth is constructed under the active MDM policy: the policy is overlaid on +// the resolved config so the login runs against the enforced values, while +// the persisted config keeps the caller-supplied ones; a caller-supplied +// management URL is ignored while MDM manages that key. A nil fetcher +// disables MDM enforcement. +func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { + policy := loaderFor(fetcher).Load() + inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath} + if _, managed := policy.GetString(mdm.KeyManagementURL); !managed { + inputCfg.ManagementURL = mgmURL } // Load the existing config when a config file is already present so an @@ -67,6 +76,10 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { if err != nil { return nil, err } + a := &Auth{policy: policy, cfgPath: cfgPath} + if err := a.setBaseConfig(cfg); err != nil { + return nil, err + } // Use a cancellable context so Stop() can abort an in-progress interactive // login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server @@ -76,14 +89,8 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { // process (decoupled from the network extension), so without this the server // lingers after the user dismisses the browser and the next connect stalls // trying to bind the same port. - ctx, cancel := context.WithCancel(context.Background()) - - return &Auth{ - ctx: ctx, - cancel: cancel, - config: cfg, - cfgPath: cfgPath, - }, nil + a.ctx, a.cancel = context.WithCancel(context.Background()) + return a, nil } // NewAuthWithConfig instantiate Auth based on existing config @@ -106,9 +113,7 @@ func (a *Auth) Stop() { } } -// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info. -// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO -// is not supported and returns false without saving the configuration. For other errors return false. +// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth. func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) { if listener == nil { log.Errorf("SaveConfigIfSSOSupported: listener is nil") @@ -136,17 +141,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) { return false, fmt.Errorf("failed to check SSO support: %v", err) } - if !supportsSSO { - return false, nil - } - - // Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - err = profilemanager.DirectWriteOutConfig(a.cfgPath, a.config) - return true, err + return supportsSSO, nil } -// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key. +// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth. func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) { if resultListener == nil { log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil") @@ -175,10 +173,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string if err != nil { return fmt.Errorf("login failed: %v", err) } - - // Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - return profilemanager.DirectWriteOutConfig(a.cfgPath, a.config) + return nil } // LoginSync performs a synchronous login check without UI interaction @@ -312,19 +307,6 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin } } - // 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. - // On iOS/tvOS, we save here because: - // 1. The config may have been modified during login (e.g., new tokens) - // 2. On tvOS, the Network Extension context may be the only place with - // write permissions to the App Group container - if a.cfgPath != "" { - if err := profilemanager.DirectWriteOutConfig(a.cfgPath, a.config); err != nil { - log.Warnf("failed to save config after login: %v", err) - } - } - // Notify caller of successful login synchronously before returning urlOpener.OnLoginSuccess() @@ -375,23 +357,44 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener return &tokenInfo, nil } -// GetConfigJSON returns the current config as a JSON string. -// This can be used by the caller to persist the config via alternative storage -// mechanisms (e.g., UserDefaults on tvOS where file writes are blocked). +// GetConfigJSON returns the config without the MDM overlay as JSON, for persisting it outside the config file (tvOS). func (a *Auth) GetConfigJSON() (string, error) { - if a.config == nil { + cfg := a.base + if cfg == nil { + cfg = a.config + } + if cfg == nil { return "", fmt.Errorf("no config available") } - return profilemanager.ConfigToJSON(a.config) + return profilemanager.ConfigToJSON(cfg) } -// SetConfigFromJSON loads config from a JSON string. -// This can be used to restore config from alternative storage mechanisms. +// SetConfigFromJSON replaces the config from JSON; the MDM overlay is applied on top for the login. func (a *Auth) SetConfigFromJSON(jsonStr string) error { cfg, err := profilemanager.ConfigFromJSON(jsonStr) if err != nil { return err } - a.config = cfg + return a.setBaseConfig(cfg) +} + +func (a *Auth) setBaseConfig(base *profilemanager.Config) error { + overlaid, err := copyConfig(base) + if err != nil { + return err + } + if a.policy != nil { + overlaid.ApplyMDMPolicy(a.policy) + } + a.base = base + a.config = overlaid return nil } + +func copyConfig(cfg *profilemanager.Config) (*profilemanager.Config, error) { + raw, err := profilemanager.ConfigToJSON(cfg) + if err != nil { + return nil, err + } + return profilemanager.ConfigFromJSON(raw) +} diff --git a/client/ios/NetBirdSDK/mdm.go b/client/ios/NetBirdSDK/mdm.go new file mode 100644 index 000000000..93a31916c --- /dev/null +++ b/client/ios/NetBirdSDK/mdm.go @@ -0,0 +1,66 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +// PolicyFetcher is implemented by the native layer to return the current +// managed configuration as a JSON-encoded object string; "" means no MDM +// source is present. +type PolicyFetcher interface { + FetchJSON() string +} + +type mdmSource struct { + loader *mdm.Loader + detector *mdm.ChangeDetector +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Client; passing nil disables MDM enforcement. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { + loader := loaderFor(p) + c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)}) +} + +// HasMDMPolicyChanged re-reads the managed configuration and reports whether +// it changed since the last observation; call it from the native OS-change +// notification and restart the engine only on true. +func (c *Client) HasMDMPolicyChanged() bool { + src := c.mdmSource.Load() + if src == nil { + return false + } + return src.detector.Changed() +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (c *Client) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON() +} + +func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { + loader := c.mdmLoader() + if cfg == nil || loader == nil { + return + } + cfg.ApplyMDMPolicy(loader.Load()) +} + +func (c *Client) mdmLoader() *mdm.Loader { + if src := c.mdmSource.Load(); src != nil { + return src.loader + } + return nil +} + +func loaderFor(p PolicyFetcher) *mdm.Loader { + if p == nil { + return mdm.NewJSONLoader(nil) + } + return mdm.NewJSONLoader(p.FetchJSON) +} diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index 39aa7ed83..5297920a3 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -3,12 +3,16 @@ package NetBirdSDK import ( + "sync/atomic" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" ) // Preferences export a subset of the internal config for gomobile type Preferences struct { configInput profilemanager.ConfigInput + mdmLoader atomic.Pointer[mdm.Loader] } // NewPreferences create new Preferences instance @@ -17,11 +21,30 @@ func NewPreferences(configPath string, stateFilePath string) *Preferences { ConfigPath: configPath, StateFilePath: stateFilePath, } - return &Preferences{ci} + return &Preferences{configInput: ci} +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Preferences instance; passing nil disables MDM enforcement. +func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) { + p.mdmLoader.Store(loaderFor(f)) +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (p *Preferences) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(p.policy()).JSON() +} + +func (p *Preferences) policy() *mdm.Policy { + return p.mdmLoader.Load().Load() } // GetManagementURL read url from config file func (p *Preferences) GetManagementURL() (string, error) { + if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok { + return mdm.CanonicalURL(v), nil + } if p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -30,7 +53,7 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - return cfg.ManagementURL.String(), err + return cfg.ManagementURL.String(), nil } // SetManagementURL store the given url and wait for commit @@ -56,17 +79,21 @@ func (p *Preferences) SetAdminURL(url string) { p.configInput.AdminURL = url } -// GetPreSharedKey read preshared key from config file -func (p *Preferences) GetPreSharedKey() (string, error) { +// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or +// enforced by MDM; the key itself is never handed to the native layer. +func (p *Preferences) HasPreSharedKey() (bool, error) { + if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok { + return true, nil + } if p.configInput.PreSharedKey != nil { - return *p.configInput.PreSharedKey, nil + return *p.configInput.PreSharedKey != "", nil } cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) if err != nil { - return "", err + return false, err } - return cfg.PreSharedKey, err + return cfg.PreSharedKey != "", nil } // SetPreSharedKey store the given key and wait for commit @@ -81,6 +108,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) { // GetRosenpassEnabled read rosenpass enabled from config file func (p *Preferences) GetRosenpassEnabled() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok { + return v, nil + } if p.configInput.RosenpassEnabled != nil { return *p.configInput.RosenpassEnabled, nil } @@ -99,6 +129,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) { // GetRosenpassPermissive read rosenpass permissive from config file func (p *Preferences) GetRosenpassPermissive() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok { + return v, nil + } if p.configInput.RosenpassPermissive != nil { return *p.configInput.RosenpassPermissive, nil } @@ -130,7 +163,8 @@ func (p *Preferences) SetDisableIPv6(disable bool) { // GetRemoteJobsAllowed reads the remote jobs opt-in from config file func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { - if p.configInput.RemoteJobsAllowed != nil { + policy := p.policy() + if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil { return *p.configInput.RemoteJobsAllowed, nil } @@ -138,10 +172,11 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { if err != nil { return false, err } + cfg.ApplyMDMPolicy(policy) if cfg.RemoteJobsAllowed == nil { return false, nil } - return *cfg.RemoteJobsAllowed, err + return *cfg.RemoteJobsAllowed, nil } // SetRemoteJobsAllowed stores the given value and waits for commit @@ -151,6 +186,9 @@ func (p *Preferences) SetRemoteJobsAllowed(allowed bool) { // Commit write out the changes into config file func (p *Preferences) Commit() error { + if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil { + return err + } // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) // which are blocked by the tvOS sandbox in App Group containers _, err := profilemanager.DirectUpdateOrCreateConfig(p.configInput) diff --git a/client/ios/NetBirdSDK/preferences_test.go b/client/ios/NetBirdSDK/preferences_test.go index 5f75e7c9a..2382e123c 100644 --- a/client/ios/NetBirdSDK/preferences_test.go +++ b/client/ios/NetBirdSDK/preferences_test.go @@ -31,14 +31,13 @@ func TestPreferences_DefaultValues(t *testing.T) { t.Errorf("invalid default management url: %s", defaultVar) } - var preSharedKey string - preSharedKey, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read default preshared key: %s", err) + t.Fatalf("failed to read default preshared key presence: %s", err) } - if preSharedKey != "" { - t.Errorf("invalid preshared key: %s", preSharedKey) + if hasPSK { + t.Errorf("unexpected preshared key presence on fresh config") } } @@ -69,13 +68,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) { } p.SetPreSharedKey(exampleString) - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != exampleString { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after staging one") } } @@ -114,12 +113,12 @@ func TestPreferences_Commit(t *testing.T) { t.Errorf("unexpected management url: %s", resp) } - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != examplePresharedKey { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after commit") } } diff --git a/client/ios/NetBirdSDK/profile_manager.go b/client/ios/NetBirdSDK/profile_manager.go index 139521c7f..df962e227 100644 --- a/client/ios/NetBirdSDK/profile_manager.go +++ b/client/ios/NetBirdSDK/profile_manager.go @@ -52,6 +52,12 @@ func NewProfileManager(configDir string) *ProfileManager { return &ProfileManager{impl: mobile.NewProfileManager(configDir, iosUsername)} } +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this ProfileManager; passing nil disables MDM enforcement. +func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) { + pm.impl.SetMDMLoader(loaderFor(f)) +} + // ListProfiles returns all available profiles, including the default profile, // with their active status set. func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { diff --git a/client/mdm/changedetector.go b/client/mdm/changedetector.go new file mode 100644 index 000000000..5c21ae355 --- /dev/null +++ b/client/mdm/changedetector.go @@ -0,0 +1,34 @@ +package mdm + +import "sync" + +// ChangeDetector tracks the last observed policy of a Loader so an +// OS-notification-driven caller can ask whether the managed configuration +// actually changed before restarting anything. +type ChangeDetector struct { + mu sync.Mutex + loader *Loader + prev *Policy +} + +// NewChangeDetector constructs a ChangeDetector seeded with the loader's +// current policy, so only a later change reports as changed. +func NewChangeDetector(loader *Loader) *ChangeDetector { + return &ChangeDetector{ + loader: loader, + prev: loader.Load(), + } +} + +// Changed re-reads the policy, logs the per-key diff, and reports whether it +// diverged from the last observation; the new snapshot becomes the baseline. +func (d *ChangeDetector) Changed() bool { + d.mu.Lock() + defer d.mu.Unlock() + curr := d.loader.Load() + if !policyChanged(d.prev, curr) { + return false + } + d.prev = curr + return true +} diff --git a/client/mdm/conflicts.go b/client/mdm/conflicts.go new file mode 100644 index 000000000..a04cfb05c --- /dev/null +++ b/client/mdm/conflicts.go @@ -0,0 +1,111 @@ +package mdm + +import "net/url" + +// PreSharedKeyRedactedSentinel is the redaction mask returned in place of a +// real pre-shared key; an incoming value equal to it is a round-trip echo, +// never an override. +const PreSharedKeyRedactedSentinel = "**********" + +// ConflictCheck is a value-aware comparison between a single requested field +// and the corresponding MDM-enforced value. +type ConflictCheck struct { + Key string + Check func(*Policy) bool +} + +// ConflictBool builds a ConflictCheck for a boolean MDM key. +func ConflictBool(key string, p *bool) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetBool(key) + return ok && want == *p + }, + } +} + +// ConflictStringPtr builds a ConflictCheck for an optional string MDM key, +// where an explicit empty value is still a request to change the setting. A +// nil p means "field not set" (no override requested). +func ConflictStringPtr(key string, p *string) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetString(key) + return ok && want == *p + }, + } +} + +// ConflictURL builds a ConflictCheck for a URL-typed MDM key; both sides are +// normalized via CanonicalURL before comparison. +func ConflictURL(key, got string) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && CanonicalURL(want) == CanonicalURL(got) + }, + } +} + +// ConflictInt64 builds a ConflictCheck for an integer MDM key. +func ConflictInt64(key string, p *int64) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetInt(key) + return ok && want == *p + }, + } +} + +// ResolveConflicts returns the names of keys whose requested value diverges +// from the policy-enforced value; keys the policy does not manage are skipped, +// a managed key without a Check counts as a conflict. +func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string { + if policy.IsEmpty() { + return nil + } + var conflicts []string + for _, c := range checks { + if !policy.HasKey(c.Key) { + continue + } + if c.Check == nil || !c.Check(policy) { + conflicts = append(conflicts, c.Key) + } + } + return conflicts +} + +// CanonicalURL normalizes a service URL by appending the scheme default port +// when none is present; unparseable input is returned unchanged. +func CanonicalURL(s string) string { + u, err := url.ParseRequestURI(s) + if err != nil { + return s + } + if u.Port() == "" { + switch u.Scheme { + case "https": + u.Host += ":443" + case "http": + u.Host += ":80" + } + } + return u.String() +} diff --git a/client/mdm/jsonloader.go b/client/mdm/jsonloader.go new file mode 100644 index 000000000..7139b0e4f --- /dev/null +++ b/client/mdm/jsonloader.go @@ -0,0 +1,34 @@ +package mdm + +import ( + "encoding/json" + + log "github.com/sirupsen/logrus" +) + +type jsonPolicyFetcher struct { + fetch func() string +} + +// NewJSONLoader constructs a Loader whose policy source is a JSON-encoded +// object string, as produced by the mobile native layers; a nil fetch +// disables MDM enforcement. +func NewJSONLoader(fetch func() string) *Loader { + if fetch == nil { + return NewLoader(nil) + } + return NewLoader(&jsonPolicyFetcher{fetch: fetch}) +} + +func (f *jsonPolicyFetcher) Fetch() map[string]any { + raw := f.fetch() + if raw == "" { + return nil + } + var out map[string]any + if err := json.Unmarshal([]byte(raw), &out); err != nil { + log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err) + return nil + } + return out +} diff --git a/client/mdm/policy.go b/client/mdm/policy.go index dac135ea6..c57c5303e 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -119,16 +119,46 @@ func NewPolicy(values map[string]any) *Policy { return &Policy{values: values} } -// LoadPolicy reads the platform-native MDM configuration. Returns an -// empty (but non-nil) Policy when no source is present, the source is -// empty, or the platform is unsupported. +// PolicyFetcher supplies the managed configuration to a Loader. Mobile +// platforms (Android / iOS) implement it to push the OS-managed values +// into the Go runtime. On every platform a non-nil fetcher takes +// precedence over the native source, which is the test seam for the +// registry / plist loaders; a nil fetcher leaves the native source in +// charge, or disables MDM enforcement where there is none. +type PolicyFetcher interface { + Fetch() map[string]any +} + +// Loader is the DI-friendly entry point for reading the active MDM +// policy. Construct one at the daemon's lifecycle owner (Server on +// desktop, gomobile-exposed bridge on mobile) and pass it to anything +// that needs to read MDM state (the reload ticker, profilemanager's +// Config). Each callsite has the Loader handed in instead of looking +// up package-level state. +type Loader struct { + fetcher PolicyFetcher +} + +// NewLoader constructs a Loader. A non-nil fetcher takes precedence over +// the platform-native source; production desktop callers pass nil so the +// registry / plist stays authoritative. +func NewLoader(f PolicyFetcher) *Loader { + return &Loader{fetcher: f} +} + +// Load reads the platform-native MDM configuration and returns a +// Policy. Returns an empty (but non-nil) Policy when no source is +// present, the source is empty, or the platform is unsupported. // // Diagnostic logging differentiates the three states: // - source absent / unsupported platform: trace log only // - source present, zero keys: info "MDM enrolled (no managed keys)" // - source present, N keys: info "MDM enrolled with N managed keys: [...]" -func LoadPolicy() *Policy { - values, err := loadPlatformPolicy() +func (l *Loader) Load() *Policy { + if l == nil { + return &Policy{values: map[string]any{}} + } + values, err := l.loadPlatform() if err != nil { log.Tracef("MDM policy load: %v", err) return &Policy{values: map[string]any{}} @@ -270,7 +300,7 @@ func (p *Policy) GetStringSlice(key string) ([]string, bool) { } // sortedKeys returns the keys of m as a deterministic, lexicographically -// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's +// sorted slice. Used internally by Policy.ManagedKeys and Loader.Load's // diagnostic log line so callers see a stable key order across runs // regardless of Go's randomised map iteration. func sortedKeys(m map[string]any) []string { diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go index 57aa1168c..4159f5b7e 100644 --- a/client/mdm/policy_darwin.go +++ b/client/mdm/policy_darwin.go @@ -25,8 +25,9 @@ import ( // writable plist, as a defense against tampered installs. const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" -// loadPlatformPolicy reads the MDM-managed configuration from the macOS -// managed-preferences plist at policyPlistPath. Returns: +// loadPlatform reads the MDM-managed configuration from the macOS +// managed-preferences plist at policyPlistPath, unless a fetcher was +// injected, in which case its values are returned instead. Returns: // - (nil, nil) when the plist is absent (device not MDM-enrolled for // NetBird, or admin has not yet pushed a payload) // - (map, nil) with N entries when N managed values are present @@ -39,13 +40,19 @@ const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" // skipped so a stray entry in the payload does not block startup. // Native plist value types map naturally onto the Policy accessor // expectations (GetString / GetBool / GetInt / GetStringSlice). -func loadPlatformPolicy() (map[string]any, error) { +func (l *Loader) loadPlatform() (map[string]any, error) { + // Honour the injected fetcher when present so tests (and any + // future non-macOS MDM channel) can short-circuit the plist read + // with a scripted policy. + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } f, err := os.Open(policyPlistPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { // Not enrolled for NetBird. Caller treats nil as // "no MDM source present". - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } return nil, fmt.Errorf("open %s: %w", policyPlistPath, err) diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go index ec25d4bb1..2e25a2bb5 100644 --- a/client/mdm/policy_mobile.go +++ b/client/mdm/policy_mobile.go @@ -2,13 +2,14 @@ package mdm -// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS, -// Kotlin/Java on Android) reads the OS managed-config store and pushes the -// resulting dictionary in-process via a gomobile entry point that lands in -// Phase 5 / Phase 6. The stub keeps the package compilable for mobile -// builds and returns (nil, nil) — the platform-absent sentinel that -// LoadPolicy in policy.go treats as "no MDM source present". -func loadPlatformPolicy() (map[string]any, error) { - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. - return nil, nil +// loadPlatform reads the OS-managed configuration via the native +// PolicyFetcher injected at Loader construction. Returns +// (nil, nil) — the platform-absent sentinel that Loader.Load treats as +// "no MDM source present" — when no fetcher was provided. +func (l *Loader) loadPlatform() (map[string]any, error) { + if l == nil || l.fetcher == nil { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. + return nil, nil + } + return l.fetcher.Fetch(), nil } diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go index f4263afa2..5d0b17cfd 100644 --- a/client/mdm/policy_other.go +++ b/client/mdm/policy_other.go @@ -2,13 +2,17 @@ package mdm -// loadPlatformPolicy returns no policy on platforms without an MDM channel -// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if -// the feature did not exist. Returns (nil, nil) — the platform-absent -// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM -// source present"; an error here would just translate to the same -// outcome with an extra log line. -func loadPlatformPolicy() (map[string]any, error) { - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. +// loadPlatform reads the MDM policy on platforms without a native MDM +// channel (Linux, FreeBSD). When no fetcher was injected the policy is +// (nil, nil) — the platform-absent sentinel that Loader.Load treats as +// "MDM enforcement disabled". A non-nil fetcher takes precedence: it +// is the test-seam used by unit tests to inject a scripted policy +// without touching the OS, and the same hook supports any future +// non-mobile OS that grows an out-of-band MDM channel. +func (l *Loader) loadPlatform() (map[string]any, error) { + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go index 6cbe69776..177fcd550 100644 --- a/client/mdm/policy_test.go +++ b/client/mdm/policy_test.go @@ -1,6 +1,7 @@ package mdm import ( + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -155,10 +156,15 @@ func TestPolicy_GetStringSlice(t *testing.T) { }) } -func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) { - // loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must - // degrade gracefully and never return nil. - p := LoadPolicy() +func TestLoader_NilFetcherReturnsEmpty(t *testing.T) { + // Loader.Load with no fetcher (desktop construction) must degrade + // gracefully and never return nil; on linux loadPlatform is a stub + // returning (nil, nil), and Load is expected to translate that + // into a non-nil empty Policy. + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + t.Skip("a nil fetcher reads the OS-managed policy on this platform") + } + p := NewLoader(nil).Load() require.NotNil(t, p) assert.True(t, p.IsEmpty()) assert.Empty(t, p.ManagedKeys()) diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go index 0c2629f98..9363db436 100644 --- a/client/mdm/policy_windows.go +++ b/client/mdm/policy_windows.go @@ -61,8 +61,9 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an } } -// loadPlatformPolicy reads the MDM-managed configuration from the -// Windows registry under HKLM\Software\Policies\NetBird. Returns: +// loadPlatform reads the MDM-managed configuration from the Windows +// registry under HKLM\Software\Policies\NetBird, unless a fetcher was +// injected, in which case its values are returned instead. Returns: // - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird) // - (map, nil) with N entries when N managed values are set (N may be 0) // - (nil, err) on open / enumerate registry errors @@ -70,12 +71,18 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an // Per-value type coercion + skip-on-error is delegated to // readRegistryValue. Unknown value names are logged and skipped so a // malformed deployment does not block startup. -func loadPlatformPolicy() (map[string]any, error) { +func (l *Loader) loadPlatform() (map[string]any, error) { + // Honour the injected fetcher when present so tests (and any + // future non-Windows MDM channel) can short-circuit the registry + // read with a scripted policy. + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE) if err != nil { if errors.Is(err, registry.ErrNotExist) { // Not enrolled. Caller treats nil as "no MDM source present". - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err) diff --git a/client/mdm/restrictions.go b/client/mdm/restrictions.go new file mode 100644 index 000000000..c8e443395 --- /dev/null +++ b/client/mdm/restrictions.go @@ -0,0 +1,89 @@ +package mdm + +import "encoding/json" + +// Fields carries the per-key MDM enforcement state for a UI: value-typed +// fields hold the enforced value (nil pointer = not managed), boolean +// fields report that the key is managed. +type Fields struct { + ManagementURL string `json:"managementURL"` + PreSharedKey bool `json:"preSharedKey"` + WireguardPort bool `json:"wireguardPort"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + AllowServerSSH *bool `json:"allowServerSSH"` + DisableAutoConnect bool `json:"disableAutoConnect"` + DisableAutostart bool `json:"disableAutostart"` + BlockInbound bool `json:"blockInbound"` + DisableMetricsCollection bool `json:"disableMetricsCollection"` + SplitTunnelMode bool `json:"splitTunnelMode"` + SplitTunnelApps bool `json:"splitTunnelApps"` + DisableAdvancedView *bool `json:"disableAdvancedView"` +} + +// Features carries the feature gates a UI must honor. +type Features struct { + DisableProfiles bool `json:"disableProfiles"` + DisableNetworks bool `json:"disableNetworks"` + DisableUpdateSettings bool `json:"disableUpdateSettings"` +} + +// Restrictions is the UI-facing enforcement snapshot; the JSON shape is +// shared by the desktop frontend and the mobile bridges. +type Restrictions struct { + MDM Fields `json:"mdm"` + Features Features `json:"features"` +} + +// BuildRestrictions derives the UI enforcement snapshot from the active +// policy. +func BuildRestrictions(policy *Policy) Restrictions { + var r Restrictions + if policy.IsEmpty() { + return r + } + + if v, ok := policy.GetString(KeyManagementURL); ok { + r.MDM.ManagementURL = CanonicalURL(v) + } + r.MDM.PreSharedKey = policy.HasKey(KeyPreSharedKey) + r.MDM.WireguardPort = policy.HasKey(KeyWireguardPort) + r.MDM.RosenpassEnabled = policy.HasKey(KeyRosenpassEnabled) + r.MDM.RosenpassPermissive = policy.HasKey(KeyRosenpassPermissive) + r.MDM.DisableClientRoutes = policy.HasKey(KeyDisableClientRoutes) + r.MDM.DisableServerRoutes = policy.HasKey(KeyDisableServerRoutes) + r.MDM.DisableAutoConnect = policy.HasKey(KeyDisableAutoConnect) + r.MDM.DisableAutostart = policy.HasKey(KeyDisableAutostart) + r.MDM.BlockInbound = policy.HasKey(KeyBlockInbound) + r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection) + r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode) + r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps) + if v, ok := policy.GetBool(KeyAllowServerSSH); ok { + r.MDM.AllowServerSSH = &v + } + if v, ok := policy.GetBool(KeyDisableAdvancedView); ok { + r.MDM.DisableAdvancedView = &v + } + + if v, ok := policy.GetBool(KeyDisableProfiles); ok { + r.Features.DisableProfiles = v + } + if v, ok := policy.GetBool(KeyDisableNetworks); ok { + r.Features.DisableNetworks = v + } + if v, ok := policy.GetBool(KeyDisableUpdateSettings); ok { + r.Features.DisableUpdateSettings = v + } + return r +} + +// JSON renders the snapshot in the shared UI JSON shape. +func (r Restrictions) JSON() (string, error) { + b, err := json.Marshal(r) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go index abd6ae233..be8fdcce7 100644 --- a/client/mdm/ticker.go +++ b/client/mdm/ticker.go @@ -15,33 +15,33 @@ import ( // instead, hence anticipating the ticker mechanism entirely. const DefaultReloadInterval = 1 * time.Minute -// policyLoader is the indirection through which the ticker reads the -// OS-native policy, both for the initial observation and on every tick. -// Production points it at LoadPolicy; tests in this package override it to -// feed a scripted sequence of policies without touching the real OS store. -var policyLoader = LoadPolicy - -// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and -// invokes the onChange callback (supplied to Run) whenever the observed -// Policy diverges from the last observation (added / removed / changed -// keys). Launch with Run from a goroutine; cancel the supplied context -// to stop. +// Ticker periodically re-reads the OS-native MDM policy via the +// injected Loader and invokes the onChange callback (supplied to Run) +// whenever the observed Policy diverges from the last observation +// (added / removed / changed keys). Launch with Run from a goroutine; +// cancel the supplied context to stop. type Ticker struct { interval time.Duration + loader *Loader prev *Policy } // NewTicker constructs a Ticker that will re-read the OS-native policy -// every reloadInterval once Run is called. -// The initial snapshot is populated by calling policyLoader at +// every reloadInterval once Run is called. The Loader is injected so +// the ticker doesn't depend on any package-level state — production +// passes the daemon-owned Loader, tests pass a fake Loader (built with +// a fake PolicyFetcher). +// +// The initial snapshot is populated by calling loader.Load() at // construction time so the first tick only fires // onChange when the policy actually changed since boot — without // this baseline the first tick would report every currently-managed // key as "added" and trigger a spurious engine restart. -func NewTicker(reloadInterval time.Duration) *Ticker { +func NewTicker(reloadInterval time.Duration, loader *Loader) *Ticker { return &Ticker{ interval: reloadInterval, - prev: policyLoader(), + loader: loader, + prev: loader.Load(), } } @@ -58,13 +58,10 @@ func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) erro log.Info("MDM policy reload ticker stopped") return case <-tk.C: - curr := policyLoader() - if policiesEqual(t.prev, curr) { + curr := t.loader.Load() + if !policyChanged(t.prev, curr) { continue } - added, removed, changed := diffPolicies(t.prev, curr) - log.Infof("MDM policy changed: added=%v removed=%v changed=%v", - added, removed, changed) prev := t.prev if err := onChange(prev, curr); err != nil { log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err) @@ -127,3 +124,12 @@ func mapOf(p *Policy) map[string]any { } return out } + +func policyChanged(prev, curr *Policy) bool { + if policiesEqual(prev, curr) { + return false + } + added, removed, changed := diffPolicies(prev, curr) + log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed) + return true +} diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go index 17f3cfc2f..29e48e728 100644 --- a/client/mdm/ticker_test.go +++ b/client/mdm/ticker_test.go @@ -13,28 +13,40 @@ import ( // testReloadInterval for speeding up the ticker cadence under `go test` const testReloadInterval = 1 * time.Second -// withPolicyLoader overrides the package-level policyLoader for the duration -// of the test so the ticker observes a scripted policy instead of the real -// OS-native store. The original loader is restored on cleanup. -func withPolicyLoader(t *testing.T, fn func() *Policy) { - t.Helper() - prev := policyLoader - policyLoader = fn - t.Cleanup(func() { policyLoader = prev }) +// fakePolicyFetcher implements PolicyFetcher returning a scripted +// policy map. Goroutine-safe so the test can mutate the script while +// the ticker is observing it. +type fakePolicyFetcher struct { + mu sync.Mutex + values map[string]any +} + +func (f *fakePolicyFetcher) Fetch() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + if f.values == nil { + return nil + } + out := make(map[string]any, len(f.values)) + for k, v := range f.values { + out[k] = v + } + return out +} + +func (f *fakePolicyFetcher) set(values map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + f.values = values } func TestTicker_FiresOnChangeWithDelta(t *testing.T) { - var mu sync.Mutex - current := NewPolicy(nil) // initial observation: empty (no enforcement) - withPolicyLoader(t, func() *Policy { - mu.Lock() - defer mu.Unlock() - return current - }) + fetcher := &fakePolicyFetcher{} // initial observation: empty (no enforcement) + loader := NewLoader(fetcher) type change struct{ prev, curr *Policy } changes := make(chan change, 1) - tk := NewTicker(testReloadInterval) + tk := NewTicker(testReloadInterval, loader) require.Equal(t, testReloadInterval, tk.interval) ctx, cancel := context.WithCancel(context.Background()) @@ -49,15 +61,13 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) { }) close(done) }() - // Stop Run and wait for it to exit before returning, so the policyLoader - // restore in t.Cleanup can't race the ticker goroutine still reading it. + // Stop Run and wait for it to exit before returning, so the test + // goroutine doesn't race the still-running ticker. defer func() { cancel(); <-done }() - // Flip the OS-observed policy from empty to one managed key. The next - // tick must detect the diff and invoke onChange. - mu.Lock() - current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"}) - mu.Unlock() + // Flip the OS-observed policy from empty to one managed key. The + // next tick must detect the diff and invoke onChange. + fetcher.set(map[string]any{KeyManagementURL: "https://mdm.example.com:443"}) select { case c := <-changes: @@ -69,12 +79,11 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) { } func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) { - withPolicyLoader(t, func() *Policy { - return NewPolicy(map[string]any{KeyBlockInbound: true}) - }) + fetcher := &fakePolicyFetcher{values: map[string]any{KeyBlockInbound: true}} + loader := NewLoader(fetcher) fired := make(chan struct{}, 1) - tk := NewTicker(testReloadInterval) + tk := NewTicker(testReloadInterval, loader) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) @@ -90,8 +99,8 @@ func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) { }() defer func() { cancel(); <-done }() - // Over ~2 ticks at the 1s test cadence the policy never changes, so the - // diff guard must suppress the callback entirely. + // Over ~2 ticks at the 1s test cadence the policy never changes, + // so the diff guard must suppress the callback entirely. select { case <-fired: t.Fatal("onChange fired despite an unchanged policy") diff --git a/client/mobile/profile_manager.go b/client/mobile/profile_manager.go index 1ddabf0a9..348b7253b 100644 --- a/client/mobile/profile_manager.go +++ b/client/mobile/profile_manager.go @@ -4,6 +4,7 @@ package mobile import ( + "errors" "fmt" "os" "path/filepath" @@ -11,6 +12,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" ) const ( @@ -22,6 +24,9 @@ const ( profilesSubdir = "profiles" ) +// ErrProfilesDisabled marks a profile mutation rejected by MDM policy. +var ErrProfilesDisabled = errors.New("profile management is disabled by MDM policy") + /* / ← app-writable config root @@ -55,6 +60,7 @@ type ProfileManager struct { configDir string username string serviceMgr *profilemanager.ServiceManager + mdmLoader *mdm.Loader } // NewProfileManager creates a profile manager rooted at configDir, the @@ -127,6 +133,9 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { // 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.checkProfilesAllowed(); err != nil { + return err + } if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ ID: profilemanager.ID(id), Username: pm.username, @@ -141,6 +150,9 @@ func (pm *ProfileManager) SwitchProfile(id string) error { // 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) { + if err := pm.checkProfilesAllowed(); err != nil { + return nil, err + } profile, err := pm.serviceMgr.AddProfile(displayName, pm.username) if err != nil { return nil, fmt.Errorf("add profile: %w", err) @@ -153,6 +165,9 @@ func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) { // 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.checkProfilesAllowed(); err != nil { + return err + } if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil { return fmt.Errorf("rename profile: %w", err) } @@ -165,6 +180,9 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error { // 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 { + if err := pm.checkProfileLogoutAllowed(id); err != nil { + return err + } configPath, err := pm.getProfileConfigPath(id) if err != nil { return err @@ -196,6 +214,9 @@ func (pm *ProfileManager) LogoutProfile(id string) error { // RemoveProfile deletes a profile. The default profile and the active profile // cannot be removed. func (pm *ProfileManager) RemoveProfile(id string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } configPath, err := pm.getProfileConfigPath(id) if err != nil { return err @@ -267,6 +288,27 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { return pm.GetStateFilePath(activeProfile.ID) } +// SetMDMLoader registers the MDM policy source consulted before profile +// mutations; a nil loader disables enforcement. +func (pm *ProfileManager) SetMDMLoader(loader *mdm.Loader) { + pm.mdmLoader = loader +} + +func (pm *ProfileManager) checkProfilesAllowed() error { + if v, ok := pm.mdmLoader.Load().GetBool(mdm.KeyDisableProfiles); ok && v { + return ErrProfilesDisabled + } + return nil +} + +func (pm *ProfileManager) checkProfileLogoutAllowed(id string) error { + active, err := pm.serviceMgr.GetActiveProfileState() + if err == nil && active.ID.String() == id { + return nil + } + return pm.checkProfilesAllowed() +} + // 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 { diff --git a/client/mobile/profile_manager_mdm_test.go b/client/mobile/profile_manager_mdm_test.go new file mode 100644 index 000000000..305becac3 --- /dev/null +++ b/client/mobile/profile_manager_mdm_test.go @@ -0,0 +1,83 @@ +package mobile + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +type fakeFetcher struct{ values map[string]any } + +func (f *fakeFetcher) Fetch() map[string]any { return f.values } + +func newTestProfileManager(t *testing.T) *ProfileManager { + t.Helper() + origDir := profilemanager.DefaultConfigPathDir + origPath := profilemanager.DefaultConfigPath + origActive := profilemanager.ActiveProfileStatePath + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origPath + profilemanager.ActiveProfileStatePath = origActive + }) + + configDir := t.TempDir() + pm := NewProfileManager(configDir, "mobile") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(configDir, defaultConfigFilename), + }) + require.NoError(t, err) + return pm +} + +func privateKeyOf(t *testing.T, pm *ProfileManager, id string) string { + t.Helper() + path, err := pm.getProfileConfigPath(id) + require.NoError(t, err) + raw, err := os.ReadFile(path) + require.NoError(t, err) + var cfg struct{ PrivateKey string } + require.NoError(t, json.Unmarshal(raw, &cfg)) + return cfg.PrivateKey +} + +func TestLogoutProfile_DisableProfiles(t *testing.T) { + pm := newTestProfileManager(t) + other, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + require.NotEmpty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName)) + require.NotEmpty(t, privateKeyOf(t, pm, other.ID)) + + pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{ + mdm.KeyDisableProfiles: true, + }})) + + err = pm.LogoutProfile(other.ID) + assert.ErrorIs(t, err, ErrProfilesDisabled) + assert.NotEmpty(t, privateKeyOf(t, pm, other.ID)) + + require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName)) + assert.Empty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName)) +} + +func TestLogoutProfile_ProfilesAllowed(t *testing.T) { + pm := newTestProfileManager(t) + other, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{ + mdm.KeyDisableProfiles: false, + }})) + + require.NoError(t, pm.LogoutProfile(other.ID)) + assert.Empty(t, privateKeyOf(t, pm, other.ID)) +} diff --git a/client/server/mdm.go b/client/server/mdm.go index b41e2b590..7a47b2a57 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -3,7 +3,6 @@ package server import ( "context" "fmt" - "net/url" "time" log "github.com/sirupsen/logrus" @@ -14,28 +13,6 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// preSharedKeyRedactedSentinel is the value GetConfig returns in place -// of an actual PSK, so a UI that round-trips the field back to the -// daemon (via SetConfig / Login) can be distinguished from a deliberate -// override. Any incoming PSK that equals this sentinel is treated as -// a no-op echo, never as a conflict with the policy. -const preSharedKeyRedactedSentinel = "**********" - -// loadMDMPolicy is the indirection used by server handlers to read the -// active MDM policy. Tests override this to inject a fake policy. -var loadMDMPolicy = mdm.LoadPolicy - -// conflictCheck is a value-aware comparison between a single field in -// the incoming request and the corresponding MDM-enforced value. It -// runs only when the field was actually set in the request (presence -// already filtered upstream); ok=true reports the policy value, ok=false -// means the policy is silent on the key — both are treated as conflicts -// to be safe (an MDM key declared as managed must hold a value). -type conflictCheck struct { - key string - check func(*mdm.Policy) (match bool) -} - // onMDMPolicyChange is invoked by the MDM reload ticker every time the // OS-native managed-config store reports a diff vs the last observation. // @@ -168,126 +145,6 @@ func (s *Server) restartEngineForMDMLocked() error { return nil } -// conflictBool builds a conflictCheck for a boolean MDM key. 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 -// boolean value equals *p. -func conflictBool(key string, p *bool) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if p == nil { - return true // absent → match by definition - } - want, ok := pol.GetBool(key) - return ok && want == *p - }, - } -} - -func canonicalURL(s string) string { - u, err := url.ParseRequestURI(s) - if err != nil { - return s - } - if u.Port() == "" { - switch u.Scheme { - case "https": - u.Host += ":443" - case "http": - u.Host += ":80" - } - } - return u.String() -} - -// conflictURL is conflictString for URL-typed keys: both sides are -// normalized via canonicalURL before comparison. -func conflictURL(key, got string) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if got == "" { - return true - } - want, ok := pol.GetString(key) - return ok && canonicalURL(want) == canonicalURL(got) - }, - } -} - -// conflictString builds a conflictCheck for a string MDM key. An empty -// `got` is treated as "field not set" (no override requested); otherwise -// the check returns true only when the policy contains the key and its -// value equals got. -func conflictString(key, got string) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if got == "" { - return true - } - want, ok := pol.GetString(key) - return ok && want == got - }, - } -} - -// 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. -func conflictInt64(key string, p *int64) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if p == nil { - return true - } - want, ok := pol.GetInt(key) - return ok && want == *p - }, - } -} - -// resolveConflicts walks the per-field checks against the active MDM -// policy and returns the names of keys whose requested value diverges -// from the policy-enforced value. Keys not present in the policy are -// skipped silently (the gate fires only for keys the admin has -// actually pushed). Returns nil for an empty policy. -func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string { - if policy.IsEmpty() { - return nil - } - var conflicts []string - for _, c := range checks { - if !policy.HasKey(c.key) { - continue - } - if !c.check(policy) { - conflicts = append(conflicts, c.key) - } - } - return conflicts -} - // mdmManagedFieldConflicts returns the names of MDM-managed keys whose // requested value in the SetConfigRequest differs from the MDM-enforced // value. A field set to the same value the policy already enforces is @@ -301,27 +158,25 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ return nil } - // PSK round-trip echo: collapse the sentinel to empty so the - // shared check treats it as "field not set". - pskGot := "" - if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel { - pskGot = *msg.OptionalPreSharedKey + pskGot := msg.OptionalPreSharedKey + if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel { + pskGot = nil } - return resolveConflicts(policy, []conflictCheck{ - conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - conflictString(mdm.KeyPreSharedKey, pskGot), - conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), - 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), - conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), - conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), - conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } @@ -424,34 +279,28 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str return nil } - // Collapse the two PSK fields + the redaction sentinel down to a - // single "got" string the shared check can compare against the - // policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated) - // is the fallback; sentinel echo is treated as "field not set". - pskGot := "" - if msg.OptionalPreSharedKey != nil { - pskGot = *msg.OptionalPreSharedKey - } else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login - pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019 + pskGot := msg.OptionalPreSharedKey + if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019 } - if pskGot == preSharedKeyRedactedSentinel { - pskGot = "" + if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel { + pskGot = nil } - return resolveConflicts(policy, []conflictCheck{ - conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - conflictString(mdm.KeyPreSharedKey, pskGot), - conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), - 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), - conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), - conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), - conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } diff --git a/client/server/server.go b/client/server/server.go index 410a9d98f..108aa8a41 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -138,6 +138,15 @@ type Server struct { // stopped by the rootCtx cancellation. mdmTicker *mdm.Ticker + // mdmLoader is the daemon-owned source of the active MDM policy. + // Constructed once during Server.Start (with a nil PolicyFetcher on + // desktop — the build-tagged Loader.loadPlatform reads the OS + // registry / plist directly) and injected into every consumer: + // mdmTicker for its periodic reload, the SetConfig / Login MDM + // gates for conflict detection, and every Config produced via + // getConfig() so its apply() picks up the same overlay. + mdmLoader *mdm.Loader + updateManager *updater.Manager jwtCache *jwtCache @@ -246,8 +255,14 @@ func (s *Server) Start() error { // Runs re-resolves Config (re-running profilemanager.Config.apply which // applies the freshly-read MDM policy as the last layer) and brings // the engine back with the new values. + if s.mdmLoader == nil { + // Desktop builds pass a nil PolicyFetcher: the Loader's + // build-tagged loadPlatform reads the OS source directly + // (registry on Windows, plist on macOS, no-op elsewhere). + s.mdmLoader = mdm.NewLoader(nil) + } if s.mdmTicker == nil { - s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval) + s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval, s.mdmLoader) go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange) } @@ -493,7 +508,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques // by the active MDM policy. The error carries an MDMManagedFields- // Violation detail listing the offending key names. Non-conflicting // fields in the same request are not applied either. - policy := loadMDMPolicy() + policy := s.mdmLoader.Load() if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil { return nil, err } @@ -636,7 +651,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro if s.checkUpdateSettingsDisabled() { return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) } - policy := loadMDMPolicy() + policy := s.mdmLoader.Load() if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { return nil, err } @@ -1487,6 +1502,12 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof return nil, false, fmt.Errorf("failed to get config: %w", err) } + // Apply the daemon-owned MDM policy on top of the just-resolved + // Config. profilemanager's apply() initialises the policy to + // empty — the Loader lives outside Config, so this overlay step + // is driven externally here. + config.ApplyMDMPolicy(s.mdmLoader.Load()) + return config, configExisted, nil } @@ -1543,6 +1564,9 @@ func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager. if err != nil { return fmt.Errorf("profile '%s' not found", profile.ID) } + // Honour any MDM-enforced ManagementURL when issuing the logout + // RPC: the user-stored value may have been overridden by policy. + config.ApplyMDMPolicy(s.mdmLoader.Load()) return s.sendLogoutRequestWithConfig(ctx, config) } @@ -2177,6 +2201,11 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) } + // Overlay the active MDM policy so the response's MDMManagedFields + // list reflects what the GUI / CLI must render as read-only. + // profilemanager.GetConfig itself returns a Config without the + // overlay (Loader lives outside profilemanager). + cfg.ApplyMDMPolicy(s.mdmLoader.Load()) managementURL := cfg.ManagementURL adminURL := cfg.AdminURL diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index ad3b7ade7..a392af6d3 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -16,14 +16,40 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook -// so SetConfig observes the supplied Policy. Restores the original loader -// at test cleanup. -func withMDMPolicy(t *testing.T, policy *mdm.Policy) { +// fakeMDMFetcher implements mdm.PolicyFetcher returning a pre-set +// policy map. Tests build one per Server instance to inject a +// scripted MDM overlay via a Loader rather than via package-level state. +type fakeMDMFetcher struct{ values map[string]any } + +func (f *fakeMDMFetcher) Fetch() map[string]any { return f.values } + +// withMDMPolicy installs an mdm.Loader on the given Server whose +// loadPlatform returns the supplied Policy's underlying values. Use +// after setupServerWithProfile to inject the scripted policy the +// SetConfig / Login MDM gates will observe. +func withMDMPolicy(t *testing.T, s *Server, policy *mdm.Policy) { t.Helper() - prev := loadMDMPolicy - loadMDMPolicy = func() *mdm.Policy { return policy } - t.Cleanup(func() { loadMDMPolicy = prev }) + values := map[string]any{} + if policy != nil { + for _, k := range policy.ManagedKeys() { + if v, ok := policy.GetString(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetInt(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetBool(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetStringSlice(k); ok { + values[k] = v + } + } + } + s.mdmLoader = mdm.NewLoader(&fakeMDMFetcher{values: values}) } // setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved: @@ -93,12 +119,11 @@ func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation } func TestSetConfig_MDMReject_SingleField(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, Username: username, @@ -110,14 +135,13 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) { } func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", mdm.KeyBlockInbound: true, mdm.KeyRosenpassEnabled: true, })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - blockInbound := false rosenpassEnabled := false _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ @@ -137,13 +161,12 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { } func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, 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{ @@ -164,12 +187,11 @@ func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) { // (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{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, 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, @@ -181,17 +203,80 @@ func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) { assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields()) } +func TestSetConfig_MDMReject_EmptyPreSharedKey(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + })) + + psk := "" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + OptionalPreSharedKey: &psk, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{mdm.KeyPreSharedKey}, v.GetFields()) +} + +func TestSetConfig_MDMAllow_PreSharedKeySentinelEcho(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + })) + + psk := mdm.PreSharedKeyRedactedSentinel + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + OptionalPreSharedKey: &psk, + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} + +func TestLoginRequestMDMConflicts_PreSharedKey(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + }) + empty := "" + sentinel := mdm.PreSharedKeyRedactedSentinel + same := "mdm-enforced-psk" + other := "user-psk" + + tests := []struct { + name string + msg *proto.LoginRequest + want []string + }{ + {name: "unset", msg: &proto.LoginRequest{}, want: nil}, + {name: "optional empty", msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, want: []string{mdm.KeyPreSharedKey}}, + {name: "optional sentinel echo", msg: &proto.LoginRequest{OptionalPreSharedKey: &sentinel}, want: nil}, + {name: "optional same value", msg: &proto.LoginRequest{OptionalPreSharedKey: &same}, want: nil}, + {name: "optional divergent", msg: &proto.LoginRequest{OptionalPreSharedKey: &other}, want: []string{mdm.KeyPreSharedKey}}, + {name: "legacy empty is unset", msg: &proto.LoginRequest{PreSharedKey: ""}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + {name: "legacy sentinel echo", msg: &proto.LoginRequest{PreSharedKey: sentinel}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + {name: "legacy divergent", msg: &proto.LoginRequest{PreSharedKey: other}, want: []string{mdm.KeyPreSharedKey}}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, loginRequestMDMConflicts(tc.msg, policy)) + }) + } +} + func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { // MDM enforces ManagementURL only; user request touches both the // enforced field AND a non-enforced field (RosenpassEnabled). // The whole request must be rejected — non-conflicting fields are not // applied either. - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", })) - s, ctx, profName, username, cfgPath := setupServerWithProfile(t) - rosenpassEnabled := true _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, @@ -213,12 +298,11 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) { // MDM enforces ManagementURL but the user only writes RosenpassEnabled. // Request must succeed. - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - rosenpassEnabled := true resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, @@ -247,12 +331,11 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: tc.mdmURL, })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - rosenpassEnabled := true resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, @@ -269,9 +352,8 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) { func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) { // No MDM policy active: any field can be written. - withMDMPolicy(t, mdm.NewPolicy(nil)) - s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(nil)) resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go index 162922579..0c67667dd 100644 --- a/client/ui/autostart_default.go +++ b/client/ui/autostart_default.go @@ -72,7 +72,7 @@ func netbirdFootprintExists() bool { // retrying autostart entry writes on every launch. A user's later disable in // Settings is never overridden: the marker guarantees at-most-once, ever. func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) { - mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy()) + mdmDisabled := autostartDisabledByMDM(mdm.NewLoader(nil).Load()) if mdmDisabled { if enabled, err := autostart.IsEnabled(ctx); err != nil { diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 91aac0467..7c20184bd 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -11,37 +11,18 @@ import ( "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/proto" ) -type MDMFields struct { - ManagementURL string `json:"managementURL"` - PreSharedKey bool `json:"preSharedKey"` - WireguardPort bool `json:"wireguardPort"` - RosenpassEnabled bool `json:"rosenpassEnabled"` - RosenpassPermissive bool `json:"rosenpassPermissive"` - DisableClientRoutes bool `json:"disableClientRoutes"` - DisableServerRoutes bool `json:"disableServerRoutes"` - AllowServerSSH *bool `json:"allowServerSSH"` - DisableAutoConnect bool `json:"disableAutoConnect"` - DisableAutostart bool `json:"disableAutostart"` - BlockInbound bool `json:"blockInbound"` - DisableMetricsCollection bool `json:"disableMetricsCollection"` - SplitTunnelMode bool `json:"splitTunnelMode"` - SplitTunnelApps bool `json:"splitTunnelApps"` - DisableAdvancedView bool `json:"disableAdvancedView"` -} +// MDMFields is the shared per-key MDM enforcement snapshot; see mdm.Fields. +type MDMFields = mdm.Fields -type Features struct { - DisableProfiles bool `json:"disableProfiles"` - DisableNetworks bool `json:"disableNetworks"` - DisableUpdateSettings bool `json:"disableUpdateSettings"` -} +// Features is the shared feature-gate snapshot; see mdm.Features. +type Features = mdm.Features -type Restrictions struct { - MDM MDMFields `json:"mdm"` - Features Features `json:"features"` -} +// Restrictions is the shared UI enforcement snapshot; see mdm.Restrictions. +type Restrictions = mdm.Restrictions // Privilege tells the frontend whether this process may perform the changes the // daemon restricts to root/administrator, whether it can ask the operating @@ -383,7 +364,7 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { }, } applyMDMRestrictions(&r.MDM, cfgResp) - r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView() + r.MDM.DisableAdvancedView = featResp.DisableAdvancedView return r, nil } @@ -411,9 +392,6 @@ func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) { if v.Field(i).Kind() != reflect.Bool { continue } - if t.Field(i).Name == "DisableAdvancedView" { - continue - } if _, ok := set[t.Field(i).Tag.Get("json")]; ok { v.Field(i).SetBool(true) } From 7a62d63a360624de9bf07d44217a4c7f2aa2160f Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:47:32 +0200 Subject: [PATCH 17/63] [management] fix delete of owner user (#7456) --- management/server/user.go | 4 ++++ management/server/user_test.go | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/management/server/user.go b/management/server/user.go index 7c0a3088d..0a711389a 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -1337,6 +1337,10 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI return fmt.Errorf("failed to get user to delete: %w", err) } + if targetUser.Role == types.UserRoleOwner && targetUser.Id != initiatorUserID { + return status.NewOwnerDeletePermissionError() + } + settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { return fmt.Errorf("failed to get account settings: %w", err) diff --git a/management/server/user_test.go b/management/server/user_test.go index 3a2414540..ec0bbc54e 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -942,6 +942,49 @@ func TestUser_DeleteUser_regularUser(t *testing.T) { } +func TestUser_deleteRegularUser_RejectsOwner(t *testing.T) { + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) + account.Users[mockTargetUserId] = &types.User{ + Id: mockTargetUserId, + Issued: types.UserIssuedAPI, + Role: types.UserRoleOwner, + } + require.NoError(t, s.SaveAccount(context.Background(), account)) + + am := DefaultAccountManager{Store: s} + + _, err = am.deleteRegularUser(context.Background(), mockAccountID, mockUserID, &types.UserInfo{ID: mockTargetUserId}) + assert.EqualError(t, err, status.NewOwnerDeletePermissionError().Error()) +} + +func TestUser_deleteRegularUser_InitiatorOwnerDeletesThemself(t *testing.T) { + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) + require.NoError(t, s.SaveAccount(context.Background(), account)) + + networkMapControllerMock := network_map.NewMockController(gomock.NewController(t)) + networkMapControllerMock.EXPECT().OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + am := DefaultAccountManager{ + Store: s, + eventStore: &activity.InMemoryEventStore{}, + networkMapController: networkMapControllerMock, + } + + _, err = am.deleteRegularUser(context.Background(), mockAccountID, mockUserID, &types.UserInfo{ID: mockUserID}) + require.NoError(t, err) + + _, err = s.GetUserByUserID(context.Background(), store.LockingStrengthNone, mockUserID) + assert.Equal(t, status.NewUserNotFoundError(mockUserID), err) +} + func TestUser_DeleteUser_RegularUsers(t *testing.T) { store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) if err != nil { From bb4de1d0088d6d440ee6ac13496e7c6c60d14113 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:06:46 +0200 Subject: [PATCH 18/63] [client] Read MDM boolean keys delivered as JSON numbers (#7471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encoding/json decodes every JSON number into float64, so the policy values the mobile loaders produce never contain int or int64. GetBool accepted both of those but not float64, so a managed boolean pushed as 1 or 0 — how some MDM consoles normalise flags — was reported as unreadable while the key still counted as managed: the policy was not applied, and the conflict gate rejected both values the user could pick for that field. The rejected-float assertion predates the JSON channel. It came with the registry and plist loaders, where a real number for a flag is a configuration mistake; on the JSON channel an integer is the only shape a number can take. GetInt already accepts float64. --- client/mdm/policy.go | 2 ++ client/mdm/policy_test.go | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/client/mdm/policy.go b/client/mdm/policy.go index c57c5303e..638fa0d80 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -235,6 +235,8 @@ func (p *Policy) GetBool(key string) (bool, bool) { return t != 0, true case int64: return t != 0, true + case float64: + return t != 0, true } return false, false } diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go index 177fcd550..ea467f861 100644 --- a/client/mdm/policy_test.go +++ b/client/mdm/policy_test.go @@ -96,7 +96,8 @@ func TestPolicy_GetBool(t *testing.T) { {"int64 nonzero", int64(2), true, true}, {"int64 zero", int64(0), false, true}, {"string garbage", "maybe", false, false}, - {"float unsupported", 1.0, false, false}, + {"float nonzero", 1.0, true, true}, + {"float zero", 0.0, false, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -156,6 +157,20 @@ func TestPolicy_GetStringSlice(t *testing.T) { }) } +// encoding/json decodes every JSON number into float64, so the mobile +// loaders never see int. +func TestJSONLoader_BoolFromNumber(t *testing.T) { + p := NewJSONLoader(func() string { return `{"blockInbound":1,"disableProfiles":0}` }).Load() + + got, ok := p.GetBool(KeyBlockInbound) + assert.True(t, ok) + assert.True(t, got) + + got, ok = p.GetBool(KeyDisableProfiles) + assert.True(t, ok) + assert.False(t, got) +} + func TestLoader_NilFetcherReturnsEmpty(t *testing.T) { // Loader.Load with no fetcher (desktop construction) must degrade // gracefully and never return nil; on linux loadPlatform is a stub From d2e62e358a07333462fa1e60587ef2964af85b1f Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:32:16 +0200 Subject: [PATCH 19/63] [client] Compare MDM-managed URLs as endpoints, not as strings (#7472) A policy that enforces a management URL refuses any SetConfig or Login whose URL differs from it. The comparison normalized only the default port, so three ways of writing the very endpoint the policy names were reported as conflicts: policy https://mgmt.example.com vs https://mgmt.example.com/ refused https://MGMT.example.com refused https://mgmt.example.com:0443 refused For an MDM-managed deployment whose stored or command-line URL is spelled differently from the policy's value, that means every settings update is refused with an MDMManagedFieldsViolation naming a field the caller did not change. `netbird up --management-url https://MGMT.example.com` reproduces it. The rules now live in util.SameServiceURL, and ConflictURL delegates: scheme and host compared case-insensitively, the effective port normalized numerically, a trailing slash ignored, and a path otherwise still part of the identity so /other remains a divergence. Unparseable input falls back to string equality. util rather than either caller, because comparing two service URLs is neither device management nor profile storage, and more than one place does it: an MDM-enforced management URL against a requested one here, a stored profile URL against a command-line one in profilemanager and the SSH gate. Every copy of these rules that drifts turns an equivalent URL into a refused request, which is how this one arose. CanonicalURL is left alone: besides comparison it is the canonical value handed to mdm.Restrictions and to the Android and iOS Preferences getters, and normalizing what those return is a separate decision. --- client/mdm/conflicts.go | 13 +++++-- client/mdm/conflicts_test.go | 40 ++++++++++++++++++++ util/serviceurl.go | 69 ++++++++++++++++++++++++++++++++++ util/serviceurl_test.go | 73 ++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 client/mdm/conflicts_test.go create mode 100644 util/serviceurl.go create mode 100644 util/serviceurl_test.go diff --git a/client/mdm/conflicts.go b/client/mdm/conflicts.go index a04cfb05c..160212afb 100644 --- a/client/mdm/conflicts.go +++ b/client/mdm/conflicts.go @@ -1,6 +1,10 @@ package mdm -import "net/url" +import ( + "net/url" + + "github.com/netbirdio/netbird/util" +) // PreSharedKeyRedactedSentinel is the redaction mask returned in place of a // real pre-shared key; an incoming value equal to it is a round-trip echo, @@ -44,8 +48,9 @@ func ConflictStringPtr(key string, p *string) ConflictCheck { } } -// ConflictURL builds a ConflictCheck for a URL-typed MDM key; both sides are -// normalized via CanonicalURL before comparison. +// ConflictURL builds a ConflictCheck for a URL-typed MDM key. The two sides are +// compared as the endpoints they address, not as strings: see +// util.SameServiceURL. func ConflictURL(key, got string) ConflictCheck { return ConflictCheck{ Key: key, @@ -54,7 +59,7 @@ func ConflictURL(key, got string) ConflictCheck { return true } want, ok := pol.GetString(key) - return ok && CanonicalURL(want) == CanonicalURL(got) + return ok && util.SameServiceURLStrings(want, got) }, } } diff --git a/client/mdm/conflicts_test.go b/client/mdm/conflicts_test.go new file mode 100644 index 000000000..d145ec103 --- /dev/null +++ b/client/mdm/conflicts_test.go @@ -0,0 +1,40 @@ +package mdm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The same spellings, through the conflict check that decides whether a request +// is refused. An enforced URL restated in another spelling addresses the very +// server the policy names, so it must not be reported as a conflict. +func TestConflictURLComparesEndpoints(t *testing.T) { + policy := NewPolicy(map[string]any{KeyManagementURL: "https://mgmt.example.com"}) + require.True(t, policy.HasKey(KeyManagementURL)) + + for _, restated := range []string{ + "https://mgmt.example.com", + "https://mgmt.example.com:443", + "https://mgmt.example.com/", + "https://MGMT.example.com", + "https://mgmt.example.com:0443", + } { + conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, restated)}) + assert.Empty(t, conflicts, "%q is the enforced endpoint written differently", restated) + } + + for _, diverging := range []string{ + "https://other.example.com", + "http://mgmt.example.com", + "https://mgmt.example.com:8443", + "https://mgmt.example.com/other", + } { + conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, diverging)}) + assert.Equal(t, []string{KeyManagementURL}, conflicts, "%q addresses another endpoint", diverging) + } + + // An unset field is not a request to change anything. + assert.Empty(t, ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, "")})) +} diff --git a/util/serviceurl.go b/util/serviceurl.go new file mode 100644 index 000000000..ffd287df1 --- /dev/null +++ b/util/serviceurl.go @@ -0,0 +1,69 @@ +package util + +import ( + "net/url" + "strconv" + "strings" +) + +// SameServiceURL reports whether two service URLs address the same endpoint. +// One endpoint can be written several ways, and every spelling below reaches +// the same server, so none of them is a divergence from another: +// +// an implicit default port https://mgmt.example.com :443 +// a zero-padded port https://mgmt.example.com:0443 +// a different host case https://MGMT.example.com +// a trailing slash https://mgmt.example.com/ +// +// A path is otherwise part of the identity: https://mgmt.example.com and +// https://mgmt.example.com/other are two endpoints. +// +// It lives here rather than next to any one caller because several of them +// compare the same kind of URL — an MDM-enforced management URL against a +// requested one, a stored profile URL against a command-line one — and every +// copy of these rules that drifts turns an equivalent URL into a refused +// request. +func SameServiceURL(a, b *url.URL) bool { + if a == nil || b == nil { + return a == b + } + + return strings.EqualFold(a.Hostname(), b.Hostname()) && + strings.EqualFold(a.Scheme, b.Scheme) && + ServiceURLPort(a) == ServiceURLPort(b) && + strings.TrimSuffix(a.Path, "/") == strings.TrimSuffix(b.Path, "/") +} + +// SameServiceURLStrings is SameServiceURL for unparsed input. Input that does +// not parse falls back to string equality, which is the strictest thing left +// to do with it. +func SameServiceURLStrings(a, b string) bool { + ua, errA := url.ParseRequestURI(a) + ub, errB := url.ParseRequestURI(b) + if errA != nil || errB != nil { + return a == b + } + + return SameServiceURL(ua, ub) +} + +// ServiceURLPort is the port a URL addresses: the one it carries, normalized +// numerically so ":0443" and ":443" are one port, or the scheme's default. +func ServiceURLPort(u *url.URL) string { + port := u.Port() + if port == "" { + switch strings.ToLower(u.Scheme) { + case "https": + return "443" + case "http": + return "80" + default: + return "" + } + } + + if n, err := strconv.Atoi(port); err == nil { + return strconv.Itoa(n) + } + return port +} diff --git a/util/serviceurl_test.go b/util/serviceurl_test.go new file mode 100644 index 000000000..af32a7c29 --- /dev/null +++ b/util/serviceurl_test.go @@ -0,0 +1,73 @@ +package util + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSameServiceURLSpellings(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + // One endpoint, written several ways. + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:443", want: true}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com/", want: true}, + {a: "https://mgmt.example.com/", b: "https://mgmt.example.com:443/", want: true}, + {a: "https://MGMT.example.com", b: "https://mgmt.example.com", want: true}, + {a: "https://mgmt.example.com:0443", b: "https://mgmt.example.com:443", want: true}, + {a: "http://mgmt.example.com", b: "http://mgmt.example.com:80", want: true}, + {a: "HTTPS://mgmt.example.com", b: "https://mgmt.example.com", want: true}, + + // Different endpoints. + {a: "https://mgmt.example.com", b: "http://mgmt.example.com", want: false}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:8443", want: false}, + {a: "https://mgmt.example.com", b: "https://other.example.com", want: false}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com/other", want: false}, + + // Unparseable input falls back to string equality. + {a: "mgmt.example.com", b: "mgmt.example.com", want: true}, + {a: "mgmt.example.com", b: "https://mgmt.example.com", want: false}, + } + + for _, tt := range tests { + t.Run(tt.a+" vs "+tt.b, func(t *testing.T) { + assert.Equal(t, tt.want, SameServiceURLStrings(tt.a, tt.b)) + assert.Equal(t, tt.want, SameServiceURLStrings(tt.b, tt.a), "the comparison must be symmetric") + }) + } +} + +// The parsed form is the primitive the string form delegates to, so it must +// answer the same for a spelling that only the parser can tell apart. +func TestSameServiceURLParsed(t *testing.T) { + parse := func(raw string) *url.URL { + t.Helper() + u, err := url.ParseRequestURI(raw) + require.NoError(t, err) + return u + } + + assert.True(t, SameServiceURL(parse("https://mgmt.example.com:0443/"), parse("https://MGMT.example.com"))) + assert.False(t, SameServiceURL(parse("https://mgmt.example.com"), parse("https://mgmt.example.com:8443"))) + + assert.True(t, SameServiceURL(nil, nil), "two absent URLs are the same absence") + assert.False(t, SameServiceURL(nil, parse("https://mgmt.example.com"))) +} + +func TestServiceURLPort(t *testing.T) { + parse := func(raw string) *url.URL { + t.Helper() + u, err := url.ParseRequestURI(raw) + require.NoError(t, err) + return u + } + + assert.Equal(t, "443", ServiceURLPort(parse("https://mgmt.example.com"))) + assert.Equal(t, "80", ServiceURLPort(parse("http://mgmt.example.com"))) + assert.Equal(t, "443", ServiceURLPort(parse("https://mgmt.example.com:0443"))) + assert.Equal(t, "8443", ServiceURLPort(parse("https://mgmt.example.com:8443"))) +} From d101f6cc46724129f954cb01ab22d1cba42ba30a Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:32:11 +0900 Subject: [PATCH 20/63] [client] Redirect DNS port 53 with UDP and TCP DNAT instead of the eBPF forwarder (#7439) --- client/internal/dns/service_listener.go | 223 +++++++++++------- client/internal/dns/service_listener_test.go | 133 +++++++++++ client/internal/ebpf/ebpf/bpf_bpfeb.go | 36 ++- client/internal/ebpf/ebpf/bpf_bpfeb.o | Bin 14408 -> 8712 bytes client/internal/ebpf/ebpf/bpf_bpfel.go | 36 ++- client/internal/ebpf/ebpf/bpf_bpfel.o | Bin 14408 -> 8712 bytes client/internal/ebpf/ebpf/dns_fwd_linux.go | 52 ---- client/internal/ebpf/ebpf/manager_linux.go | 7 +- .../internal/ebpf/ebpf/manager_linux_test.go | 17 +- client/internal/ebpf/ebpf/src/bpf_map_def.h | 16 ++ client/internal/ebpf/ebpf/src/dns_fwd.c | 67 ------ client/internal/ebpf/ebpf/src/prog.c | 6 - client/internal/ebpf/ebpf/src/readme.md | 18 +- client/internal/ebpf/manager/manager.go | 6 +- 14 files changed, 363 insertions(+), 254 deletions(-) delete mode 100644 client/internal/ebpf/ebpf/dns_fwd_linux.go create mode 100644 client/internal/ebpf/ebpf/src/bpf_map_def.h delete mode 100644 client/internal/ebpf/ebpf/src/dns_fwd.c diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 3dc29c4dc..d65a727b1 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "runtime" + "slices" "strconv" "sync" "time" @@ -17,17 +18,20 @@ import ( nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/internal/ebpf" - ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager" ) const ( customPort = 5053 + // randomPortAttempts bounds the search for a port free on both protocols. + randomPortAttempts = 5 ) var ( defaultIP = netip.MustParseAddr("127.0.0.1") customIP = netip.MustParseAddr("127.0.0.153") + + // dnatProtocols are the protocols the port 53 redirect covers. + dnatProtocols = []firewall.Protocol{firewall.ProtocolUDP, firewall.ProtocolTCP} ) type serviceViaListener struct { @@ -40,9 +44,20 @@ type serviceViaListener struct { listenPort uint16 listenerIsRunning bool listenerFlagLock sync.Mutex - ebpfService ebpfMgr.Manager firewall Firewall - tcpDNATConfigured bool + // dnatRules holds the port 53 redirects that are installed and not yet + // removed, so a removal that fails can be retried. + dnatRules []dnatRule +} + +// dnatRule is a port 53 redirect as it was installed. The target is kept with +// the rule because the listener can come back on a different address or port, +// and a retried removal has to name the address and port the rule was added +// with, not the ones in use now. +type dnatRule struct { + protocol firewall.Protocol + ip netip.Addr + port uint16 } func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener { @@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error { } }() - // When eBPF redirects UDP port 53 to our listen port, TCP still needs - // a DNAT rule because eBPF only handles UDP. - if s.ebpfService != nil && s.firewall != nil && s.listenPort != DefaultPort { - if err := s.firewall.AddOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil { - log.Warnf("failed to add DNS TCP DNAT rule, TCP DNS on port 53 will not work: %v", err) - } else { - s.tcpDNATConfigured = true - log.Infof("added DNS TCP DNAT rule: %s:%d -> %s:%d", s.listenIP, DefaultPort, s.listenIP, s.listenPort) - } + if s.listenPort != DefaultPort { + s.setupDNAT() } return nil } +// setupDNAT redirects port 53 to the port the DNS server actually listens on. +// Both protocols must be redirected or none: RuntimePort reports port 53 only +// while the full redirect is in place, so a half-configured redirect would +// advertise a resolver that answers over one protocol. +func (s *serviceViaListener) setupDNAT() { + if s.firewall == nil { + log.Errorf("no firewall manager available to redirect DNS port %d to %d, "+ + "clients pointed at %s will not reach the resolver", DefaultPort, s.listenPort, s.listenIP) + return + } + + // Clear whatever an earlier removal left behind first. Those rules can point + // at an address or port this listener no longer uses, and they are matched + // before anything added now, so adding a redirect on top of one would keep + // sending port 53 traffic to the previous listener while reporting the + // redirect as complete. The rules stay recorded for a later attempt. + if err := s.removeDNAT(); err != nil { + log.Errorf("failed to remove stale DNS DNAT rules, leaving port %d redirected to the previous listener: %v", + DefaultPort, err) + return + } + + for _, proto := range dnatProtocols { + if err := s.firewall.AddOutputDNAT(s.listenIP, proto, DefaultPort, s.listenPort); err != nil { + log.Errorf("failed to add DNS %s DNAT rule, DNS on port %d will not work: %v", + proto, DefaultPort, err) + if err := s.removeDNAT(); err != nil { + log.Warnf("failed to roll back DNS DNAT rules, retrying on stop: %v", err) + } + return + } + s.dnatRules = append(s.dnatRules, dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort}) + } + + log.Infof("added DNS DNAT rules: %s:%d -> %s:%d (UDP + TCP)", s.listenIP, DefaultPort, s.listenIP, s.listenPort) +} + +// removeDNAT removes every installed port 53 redirect. A rule whose removal +// fails stays recorded so a later setup or Stop retries it, rather than leaving +// port 53 pointing at a resolver that is no longer listening. +func (s *serviceViaListener) removeDNAT() error { + if s.firewall == nil { + return nil + } + + var merr *multierror.Error + var remaining []dnatRule + for _, rule := range s.dnatRules { + if err := s.firewall.RemoveOutputDNAT(rule.ip, rule.protocol, DefaultPort, rule.port); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove DNS %s DNAT rule for %s:%d: %w", + rule.protocol, rule.ip, rule.port, err)) + remaining = append(remaining, rule) + } + } + s.dnatRules = remaining + + return nberrors.FormatErrorOrNil(merr) +} + func (s *serviceViaListener) Stop() error { s.listenerFlagLock.Lock() defer s.listenerFlagLock.Unlock() + var merr *multierror.Error + + // Redirects are removed even when the listener is already stopped, so that + // a removal which failed earlier is retried instead of leaving port 53 + // pointing at a resolver that no longer listens. + if err := s.removeDNAT(); err != nil { + merr = multierror.Append(merr, err) + } + if !s.listenerIsRunning { - return nil + return nberrors.FormatErrorOrNil(merr) } s.listenerIsRunning = false ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - var merr *multierror.Error - if err := s.server.ShutdownContext(ctx); err != nil { merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err)) } @@ -148,19 +222,6 @@ func (s *serviceViaListener) Stop() error { merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err)) } - if s.tcpDNATConfigured && s.firewall != nil { - if err := s.firewall.RemoveOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove DNS TCP DNAT rule: %w", err)) - } - s.tcpDNATConfigured = false - } - - if s.ebpfService != nil { - if err := s.ebpfService.FreeDNSFwd(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err)) - } - } - return nberrors.FormatErrorOrNil(merr) } @@ -177,11 +238,23 @@ func (s *serviceViaListener) RuntimePort() int { s.listenerFlagLock.Lock() defer s.listenerFlagLock.Unlock() - if s.ebpfService != nil { + if s.redirectInstalled() { return DefaultPort - } else { - return int(s.listenPort) } + return int(s.listenPort) +} + +// redirectInstalled reports whether every protocol is redirected from port 53 +// to the address and port the listener currently serves. Rules left over from +// an earlier listener do not count. +func (s *serviceViaListener) redirectInstalled() bool { + for _, proto := range dnatProtocols { + current := dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort} + if !slices.Contains(s.dnatRules, current) { + return false + } + } + return true } func (s *serviceViaListener) RuntimeIP() netip.Addr { @@ -190,30 +263,29 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr { // evalListenAddress figures out the listen address for the DNS server. // IPv4-only: all peers have a v4 overlay address, and DNS config points to v4. -// First checks port 53 on WG interface or lo, then tries eBPF on a random port, -// then falls back to port 5053. +// Prefers port 53 on the overlay interface or lo, so no redirect is needed at +// all; when it is taken it falls back to port 5053 and then to a random free +// port, both of which need the port 53 redirect set up by setupDNAT. func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) { if s.customAddr != nil { return s.customAddr.Addr(), s.customAddr.Port(), nil } - ip, ok := s.testFreePort(DefaultPort) - if ok { + if ip, ok := s.testFreePort(DefaultPort); ok { return ip, DefaultPort, nil } - ebpfSrv, port, ok := s.tryToUseeBPF() - if ok { - s.ebpfService = ebpfSrv - return s.wgInterface.Address().IP, port, nil - } - - ip, ok = s.testFreePort(customPort) - if ok { + if ip, ok := s.testFreePort(customPort); ok { return ip, customPort, nil } - return netip.Addr{}, 0, fmt.Errorf("failed to find a free port for DNS server") + ip := s.wgInterface.Address().IP + port, err := s.randomFreePort(ip) + if err != nil { + return netip.Addr{}, 0, fmt.Errorf("find a free port for DNS server: %w", err) + } + + return ip, port, nil } func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) { @@ -260,48 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool { return true } -// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53. -// This is needed because on some operating systems if we start a DNS server not on a default port 53, -// the domain name resolution won't work. So, in case we are running on Linux and picked a free -// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward -// it to a local DNS server running on the chosen port. -func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) { - if runtime.GOOS != "linux" { - return nil, 0, false +// randomFreePort returns a port that is free on ip for both UDP and TCP, since +// the DNS server binds both. The probe listeners are closed again, so the port +// is only likely, not guaranteed, to still be free when the server binds it. +func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) { + for range randomPortAttempts { + probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{}) + if err != nil { + return 0, fmt.Errorf("bind random port: %w", err) + } + + port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port) + if err := probeListener.Close(); err != nil { + return 0, fmt.Errorf("free up probed port: %w", err) + } + + if s.tryToBind(ip, int(port)) { + return port, nil + } } - port, err := s.generateFreePort() //nolint:staticcheck,unused - if err != nil { - log.Warnf("failed to generate a free port for eBPF DNS forwarder server: %s", err) - return nil, 0, false - } - - ebpfSrv := ebpf.GetEbpfManagerInstance() - err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port)) - if err != nil { - log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err) - return nil, 0, false - } - - return ebpfSrv, port, true -} - -func (s *serviceViaListener) generateFreePort() (uint16, error) { - ok := s.tryToBind(s.wgInterface.Address().IP, customPort) - if ok { - return customPort, nil - } - - probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{}) - if err != nil { - log.Debugf("failed to bind random port for DNS: %s", err) - return 0, err - } - - port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port) - if err = probeListener.Close(); err != nil { - log.Debugf("failed to free up DNS port: %s", err) - return 0, err - } - return port, nil + return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts) } diff --git a/client/internal/dns/service_listener_test.go b/client/internal/dns/service_listener_test.go index 90ef71d19..b158a79fd 100644 --- a/client/internal/dns/service_listener_test.go +++ b/client/internal/dns/service_listener_test.go @@ -1,6 +1,7 @@ package dns import ( + "errors" "fmt" "net" "net/netip" @@ -10,6 +11,8 @@ import ( "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" ) func TestServiceViaListener_TCPAndUDP(t *testing.T) { @@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) { require.NotEmpty(t, tcpResp.Answer) assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP") } + +type dnatCall struct { + rule dnatRule + added bool +} + +// fakeFirewall records DNAT calls and fails the ones named in addErrs/removeErrs. +type fakeFirewall struct { + calls []dnatCall + addErrs map[firewall.Protocol]error + removeErrs map[firewall.Protocol]error +} + +func (f *fakeFirewall) AddOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error { + if err := f.addErrs[protocol]; err != nil { + return err + } + f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}, added: true}) + return nil +} + +func (f *fakeFirewall) RemoveOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error { + if err := f.removeErrs[protocol]; err != nil { + return err + } + f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}}) + return nil +} + +func newDNATTestService(fw Firewall) *serviceViaListener { + return &serviceViaListener{ + listenIP: netip.MustParseAddr("100.64.0.1"), + listenPort: customPort, + firewall: fw, + } +} + +func TestSetupDNAT_BothProtocols(t *testing.T) { + svc := newDNATTestService(&fakeFirewall{}) + + svc.setupDNAT() + + assert.Len(t, svc.dnatRules, len(dnatProtocols)) + assert.Equal(t, DefaultPort, svc.RuntimePort(), "port 53 is advertised once both redirects are installed") +} + +func TestSetupDNAT_RollsBackPartialRedirect(t *testing.T) { + fw := &fakeFirewall{addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}} + svc := newDNATTestService(fw) + + svc.setupDNAT() + + assert.Empty(t, svc.dnatRules, "the UDP redirect installed before the failure must be rolled back") + assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "an incomplete redirect must not advertise port 53") + udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort} + assert.Contains(t, fw.calls, dnatCall{rule: udp}, "UDP removal should have been attempted") +} + +// A rollback that fails must keep the rule recorded, so port 53 is not left +// redirected to a resolver that no longer listens. +func TestStop_RetriesFailedDNATRemoval(t *testing.T) { + fw := &fakeFirewall{ + addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}, + removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}, + } + svc := newDNATTestService(fw) + + svc.setupDNAT() + udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort} + require.Equal(t, []dnatRule{udp}, svc.dnatRules, "a failed rollback keeps the rule for a later retry") + + require.Error(t, svc.Stop(), "the failing removal should be reported") + require.Equal(t, []dnatRule{udp}, svc.dnatRules) + + delete(fw.removeErrs, firewall.ProtocolUDP) + require.NoError(t, svc.Stop(), "a later stop retries the removal") + assert.Empty(t, svc.dnatRules) +} + +// A stale rule that cannot be removed is matched before anything added now, so +// no new redirect may be installed on top of it and port 53 must not be +// advertised as reaching this listener. +func TestSetupDNAT_AbortsWhileStaleRuleRemains(t *testing.T) { + fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}} + svc := newDNATTestService(fw) + stalePort := svc.listenPort + + svc.setupDNAT() + require.Error(t, svc.Stop()) + staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort} + require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules) + + svc.listenPort = stalePort + 1 + fw.calls = nil + + svc.setupDNAT() + + assert.Equal(t, []dnatRule{staleUDP}, svc.dnatRules, "the stale rule stays recorded for a later attempt") + for _, call := range fw.calls { + assert.False(t, call.added, "no redirect may be installed while a stale one is still in place") + } + assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "port 53 must not be advertised") +} + +// A rule left behind by a failed removal must be removed with the address and +// port it was installed with, even when the listener has since moved to another +// port, and it must not count towards the redirect the new listener advertises. +func TestSetupDNAT_ClearsStaleRuleAfterPortChange(t *testing.T) { + fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}} + svc := newDNATTestService(fw) + stalePort := svc.listenPort + + svc.setupDNAT() + require.Error(t, svc.Stop()) + staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort} + require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules) + + delete(fw.removeErrs, firewall.ProtocolUDP) + svc.listenPort = stalePort + 1 + fw.calls = nil + + svc.setupDNAT() + + assert.Contains(t, fw.calls, dnatCall{rule: staleUDP}, "the stale rule must be removed with its original port") + assert.Len(t, svc.dnatRules, len(dnatProtocols)) + assert.Equal(t, DefaultPort, svc.RuntimePort(), "the new listener is fully redirected") + for _, rule := range svc.dnatRules { + assert.Equal(t, svc.listenPort, rule.port, "only rules for the current listener remain") + } +} diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.go b/client/internal/ebpf/ebpf/bpf_bpfeb.go index 04b19883b..4b6230217 100644 --- a/client/internal/ebpf/ebpf/bpf_bpfeb.go +++ b/client/internal/ebpf/ebpf/bpf_bpfeb.go @@ -1,5 +1,5 @@ // Code generated by bpf2go; DO NOT EDIT. -//go:build arm64be || armbe || mips || mips64 || mips64p32 || ppc64 || s390 || s390x || sparc || sparc64 +//go:build mips || mips64 || ppc64 || s390x package ebpf @@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error { type bpfSpecs struct { bpfProgramSpecs bpfMapSpecs + bpfVariableSpecs } -// bpfSpecs contains programs before they are loaded into the kernel. +// bpfProgramSpecs contains programs before they are loaded into the kernel. // // It can be passed ebpf.CollectionSpec.Assign. type bpfProgramSpecs struct { @@ -61,17 +62,28 @@ type bpfProgramSpecs struct { // It can be passed ebpf.CollectionSpec.Assign. type bpfMapSpecs struct { NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` - NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` } +// bpfVariableSpecs contains global variables before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type bpfVariableSpecs struct { + FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"` + WgPort *ebpf.VariableSpec `ebpf:"wg_port"` +} + // bpfObjects contains all objects after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfObjects struct { bpfPrograms bpfMaps + bpfVariables } func (o *bpfObjects) Close() error { @@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error { // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfMaps struct { NbFeatures *ebpf.Map `ebpf:"nb_features"` - NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` } func (m *bpfMaps) Close() error { return _BpfClose( m.NbFeatures, - m.NbMapDnsIp, - m.NbMapDnsPort, m.NbWgProxySettingsMap, ) } +// bpfVariables contains all global variables after they have been loaded into the kernel. +// +// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. +type bpfVariables struct { + FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.Variable `ebpf:"proxy_port"` + WgPort *ebpf.Variable `ebpf:"wg_port"` +} + // bpfPrograms contains all programs after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 7433ad740ac150d19705f49d188d055d3c4c8cc2..b435d49647544d14fc150e72031fe0251e485a42 100644 GIT binary patch literal 8712 zcmds6Z;Vw(6`%X|57^drt3cP5wzCztZz(LhfRuzl9taf-DNh8UhI;qyzPr26ef!?y z-M6qvwHs3nsSV*nO;%&NhGEpi17nPP57XZ#2EbjX3pH5 zH?R@yHz%1p=Xd7JIcLtCnLGEr*Z1syDVK{>RwDHe&>kb}0rAXog9`HOQBiM?p|eM? z&PYRi-NNYNh$U7kWkytFTqr-)XXPGLFZ6YDgwuE3>g`!dulNyNHlvjKmNlm?k6PmL zaodLWE00_L${y84YN`Iq;{Rc-;M%=%pisrA2w~jdW7wS=2I;{u()2s#E^S}9fy0= z1~iYm^|0|!5!Qbl`$Iqf@AIQyJ;Qn5CqJGe|G&tOJmryBYuF-R%y=(93_r{d%A!Qx z$;ABP+OvHKmE|7Zr;Gd5pWi`7W6AiykP%zE%S0wA?6}BC!3LD_oLh!OANHj-4XDQa3IPu(8rwLz+gvTl0NL$=(x~`Pjph`I2HQxjYT;#G4h4w!V;YYPfk7) zY>|oH2LDl1l;}Hnkk+6VM8RXAe;_n%y(_jED#$&EF1siY#W@!J1Oqu1Dx;s;7Ecvj z6`D4FEwl#h;x+^xCqut+#74hC3q8?wv>!ox!}*0)1HUh8juUAzwDV5_!z1fP>q}8g_CQ>!nN@iPXa6{T0+i-FsZZzw&rv}HAMmbTc&Ln!f z9&L=r)1^jSt=Fe!8gWuhrt|%^(b$P+$|$u?_fvK%Ic0Sja|7Fq!pgYL_Zh7N`n2H~ zb4CPq)|xIW`ax~eH_THE3xbWwR=t+yttE7RMO7M;<)*4kR@I55nO5pG)vBkct+<-h zRHdxOo27}kK0dBmt*U~*t@;=uIX0P$O{qAZ*{Wu0X=S38l=awTsR`C-GQ5R7cxGMc zB%A>=otXx^R4zADm9kM~^W>;a8#xNkY*eMzQZr@aU|OGPjwPy`q%CC}Yp_RAVz%7K zmVylJGxBMxIWyMMW|~^BtzdzKWi{)yXx(yff&3-eu7~t%ujwp&z`kguv3`f~U-#L_ zR`VN&Hx0cwv}Nj5=RjGvIyMPx$aF0^8JE+R%Lcnri{8jm8cinpSj{1u zES>_Su~T3q889p4yRh9DbFyAP#i7@CWt$1`bQvqvDw#)|)GUgp6R@z2nn{|*mGMfg zoXo1`Y}I~vR;evF0kWCsWk)B zaCoUL>O{O<=~(`r%y_abPt+?g#R+q+(4V)Qfqb=In;3u(TzK1SqqjJMC&$twtTgkC z+tQPXY~--DDY~y%Jb2*Hf%x$5q6;=Pu61$J{d&BLV?l7X{V06z>6fu2?Uh@t za)MLkR3lM5Eg1-9jy+3Gmu3j86LU`8-B!yxXB}G9 z&dsPJ!sEO3NmTcaMGnfcvo=mHp=j>z}CIbH0WVmOL;ggk1lu^ z{+qk0M%Z6g>ifjkY`?+qH*)^ZD)k=krZ=UZ7+0L=mWL^STky9L3f!r3J%WFs)Gg4t zUctXt>Sr1lmGR2mRq7h<@VOz2JFvFD;?-rV#fEIYySza> zOuyuPmS-++8q)9bW>&Dv8_ds)CuW{3Se)ez{5SqGkKn({vmEr8ytyR(#LSy39;UpT zpX5!JSCThbUP<0WcVxWFo8=Z~dDCO#$-KE9p>N63fgs=88S$)eeEKdW>H{JCNDC89 z#P46n=UiO*7YN~-Ty}f~yPX-xh=^+|e6x3?hoAe7Z`R)N^NW15Cc;;fn1J)&@#xnA zHW|Lh+sX={ZQyseajm^ifzR0bg?eB1o;eBH@*XBtUTH~nmOa}X3(6k!}~$wo#yhdvkaR4I{gjMjF({Zn<``FF!lL( zH!$~*>*sv^e&&mhX9LW5`2F(%J|Eyq0lpgG8v(u*;5+R&S{~ru0Otd|HNblVd^Et5 z0iF%;nE=lR_9-0}eT1~?z!tpP3qd+VPY2{7}?@1G0sxd1N& z_(FiM1o&EjZwB~wJ2u_)37U1^8xw zZ?|K!O}+V>^TfwGkRJ+gA;84|j|8~k@KdXLS3bS^+4kRO?*C0T*i5SOLma7Hhh7@w zuW}FYDQP~(%;(pAe%o{7v(G#KQ;#T_c4f{l3Jxv zHJF~7Hm!UYn)zTBYX7cX!|_%&8{-!(`Xrm7c#~xgn|3(fD3Hy|k zFox>J2kX#z$w=GZ)P*#SKmLp0IDf+NH)W77PnPfJb>OA!-=r`~INr&#?+&Vu$&=Fg zM@u}!9P^8>?{DZ>D*jw|mUF0#zx%(?&6n}th`NOR$F86crE?AszxehC(XoX6-Z?v{ zvxt`VdADNm?f<-fcfX1Ioayo(tFH-i_jcFm`^y;l>)~Y3t|(YwzI!+(oPY1X0mtFu A(f|Me literal 14408 zcmds7Z)_aLb)UUEN@SfTR8rSW@sBL2kma5gDW(CzzQ{647qRzFyd`-M zd3U-yN~cz>*}^Uuphfw?MTo*kh}JELAVv8hfdwRi_`yMlwkX&(HCQA~RiFXrG#}Wa zKxm+d`}@tj*_#`ciHf@YkO6n+{ocHJ^WK}8x3jl=`TT`XrBZ=PN}zrS+F>LuAdYuy zl#}}b74-FLI=S_Q38{%!4Gi8a7~+1V4v;EJzi*j3^!rWr8~JLt^@yptJrJnCqKTiq2-5<~T5Bm;L{{O)5k5TWv`t93S zeuTZz|Ab1hesNUoXWci6Xs`vkUo!#H75Zb%f znO%psWj#M1DwuY+obRuV4}?EX`S&p{A93}`zTR{H6%U(r1iL?gWb^W`=vjL2y|UgV zSQq`Ohx+_^v3|c9vQ@7ey-v_iuV1^4?D%$l`s-+~{k>PbZWr`$>i&YNkSac)GP=HQ z__q(6dVi?v!K3}EOzP?b>SuL**sS;IkVthu+n%=)G2|X#1M&)Y!hy2RE=rlWs1(-0 zFx&Tv$4^~8I;jrQ-+RbM4{14@kFJ`2Nj^HM9?|VTIUoIk$eDapH~o@)bX8^9?#)Yp zc{xJ|dErfB!P(C`cW;1fcZ(2V%{= z$?Iouxy81zeqY)iQ%~hDK-s^3L7UQN2J3o5DPBE)POKAvu`3XR0~p8W<$gQ_$qFz&w>7u(Bq)L z0{T8&d0Z?oNfp5VDipKeJP@o~yMa00UrQTubny2=Bc8#xMUJk}mhZRa5D^mtVcD<* z{|;F>73fK0U&sWzs6U7cCHN6;q(i94h=LD+zE5b3l{z3}GgOef7hT36h~n9mdI%la z7b;Vani_YNIwCa37!f*zO*U@B;L#b{jmK*0G1SnLI)(bfs2{g>p$>s_GUu3EcWtC! z625*8?5!_T7P*hIKL%~q>d=4(+r8_#7ARW55DJC$ok!b23e65ognW8kz=GL`MOiUz`8 z*E}NtwKL6XWi~sQw4YYT<{R~6ty3qW6UUFvmMinEqtli7W97=^?0hLccDdYa9J_p@ z88@yMYeO?oR*#$W^-35;m&uAItCdExSZPLMqhXfJmY7ki)|>b24TU{_$Vxm4v;A#- zu2bZSTaE0XBXe5)hAP))N_AD9nN`=~dZS#esAjc+s~OG46;&>&sd{lbs!mO*W^-26 z>eXg-60&`XRonQ_QE~sRjxGEd`TPFsLt0XV^xY9O{ER1u|-j$RjTR9#S$S79h_}6>+_RM ztQj~NhM3YLp|FhJa1gbPRe37RauJw#oe78C2FM+Kg2RVr&V)~$f9CwfCoe?jo_Q|H zKYQ^~c<2zuKs%kGfGjpySy;89(_go85W5z`tZp?v+V0v4(W^sc8_J2niC)|DF2hcn zZKdbDy|?tPx;`CYHd{AjTm7UxWyO;%%3bCxN3BbT&RvjNs|{mY1gmjSH<<|hZ^x!> z?VTW~z2c1#r}aXyZX_$kGXCdF#dQaZCA@hvOosLrPkU|j+w?LzGB$j2_{8zCu>l(& zu0MAnKvonEm<(n1R#=IzN2Nwn)~QZcjcnT*iNDsQw<#hqYy#~lKw`BDlBH>cTocad z!K|#)ZgxV4DJH;kC2Z+tQQxAtVo)>}gN4Lij~htKQ{_r2ZmD`Jx;h`v$B}_?XX|9M zrI5?Ho8j9uXHT97gjluQ>yCh(Kz5ibg@fpC13MKLk&ojB0Tk=pTL}Y?Oa3u1;AmXQ@BdhW$Yx`K)jwsq*s)I{g_zbkMvU9+35?Y?oPhPzE=UD!x!FLY!g+5*Q`#R3RP4b6v5kp6d zV^9b2EBNElI*03zapC=Ii?2rGxNJLKn^ZyY50zTNyP;Y%I5?}+YkY5568r*Qzc}8C zgQv_#$A_G0TunfsRI7Y&9#@litD$rRQQU&BKYsH+^BC=@b{Fu#NQ6q91Po2h>!07 zBc(pW{yXjd1@?#kY|mgl%;I$@-R)q=r?Upb9@T<9+yh$vno4Jkl z3VO+4Yk&HdV6i{FE?DeOTYHINfBKHJi~am5NBe{Rv_C@`$No&{VA!9r_I_KbH;8l6 zPR#yde^(dQKjtHoH|-i@zB3aJrhGv#-gh@JPniY5&ndMHTQZA+r6NvXf$>*uP$jPDNOt8>dQD_!;>hwOv>pCN+Z8o#7e3F9!2XpFr!1G|_iKerSbPMZ^ZD6cz+-A#u?y(gV}#Z@D1b%_{}_{<%!u2KQw+rsjnf9YT3bTUlIJ5 z@QZlW!5;x$b1>Usr`~6;O5WM@@Rq?gk0LZ$KFK@Xf^FXE1x74v9u43p_3J##yfZA= z=A8+Hle|+9%sc~KLCwL4hs{e#-dQp28nYe#YJWa2d1uYR%sU$n9s=HUFyfKqsqdP+ z6KqR6^9c3tIJh5p*TEy$$Gr~D0*n7T4?(}pQ%T+#HuAXZ!n~#D7xDQH<#P_k{4sBJ zwCByb&8(SypPF$n_I+yEV7u>A>w>Xwb>7+#{5{EAn-1of1nq)`Y|PqfZW|2V6P?o^>%yk^)_EWFQqAXr_E0lgo!HnoWn<) zZ3+I(;UiwQ1aGzZjJqwt-#Yvy%Z2_wZS$FrZPDKqN-c7eZ{HnLRkXP|`7_}LO* z&sn~H{z^meTW!AHzn1^c4u4yisPymiqCe&Owb+*Dy0#_whc-W*vs~1#^Fi!jlAE~R z#sgr#$A1v`o8a&B__X66!SDWW_>rTB{aIK)ms@L1C8xP29RZ2T{gi$XiU=!~nsv4F?^A9VP=z$}d0afgqgbRqv?@a6M{ za69dw?m$|okM9f?kAuc}Ho*nZ4>*|d?sM=8Xyn;M{!S;>YMt={Z@eG}d1t&}0@xWZ zm;rXi3zmVM@pN9{^HuEE&o4)xe%`p4_VfFX$fp)SKjh#w(DLm9ls>n1$Zvre;*$P6 zr>cGjGk#8g=If63C17X#^a`*uetHAg?SBW@mEQplamn}@($4sq0G zqYl0W+KH#4f6jcVZD418=<|#dPko+o=PL*KK3p=MKEF8n?fE5XXS`i~yPY_d2X_3? z``z(Z=Rqevd=GKsncDRF(;ru!R&*JZZro6=* zkNO06fz;_moJv`yz84tHSyy)R&53hRomWMYyyyf9- z5ASy3RJVuwJe>9LDG!f(c*4Uo9&UMf(ZkCgUiI)T4{vyQ%fs6q-tELX-#hDz=YMy- z>HOigpYqzrJv`yz84tHSyy)R&53hRomWMYyyyf9-5ASwj{_f%Wo9Xj#*2AYfJnrEM z56^hG<>5sSFMD_m*vTiEbq_ONyX|*8ywizwzINohdcF40!^0lVc{uOkf`@A!Uhwde zhgUqj=HYb@Z+iHShj%)$J{CLv@9Xv2Lk|yoIOpNKhYKFAd3eFYOCDbF@S2C$J-q4R zJ09NY#QK=-_`ko`YY#mW2 zbA~PB`z@^IcU*9-Pst$s+Qx?DgYVGn?6tW_|F1}Sy8V5M?e;>HEM8Va zjT>{imakamo7P<)>U2yINp<1u+2heKnQBDWW6>$Xk?5pwPp}?~9y4%M>JeR!M90Y< zjmB6Fn`$f?6rDEB@}@UE$oJD>5kWIYp^a}|2an&#lJTv5V6k*tzxxbM?p6;Oui&!wd-Kq}DLraZ+sf_# zGMLsLuYXTYI{cX5LxLh?_833u@A;L?1G|R2;bQT}KWo@Y+yo}_!2Ipi#a4uM&$vM! zy5e#{JN^eP3QBuUYN@gkH~qT{=1S*sw%K@*H;T*J&o-y@@qG`adqr36n=`(`cZ9vh zA6PJ5x><89{3fo8Gk$QO`rZW{w*_fzS2Rr**@qUo_6DB@*PkB|?4K}1dJ8puY93jsQjMRQ=QEnks1|6h^OV=s z&+C!<78+Gt2yl$H^3)}VXU|m{RYF+w4)6>1i&i75HrrQlO z{k-Y@eHGRid0X96nptXmTKqp*-?pfp@0X2Fn|e{}+kNd zahQ+SK0P6qYcTzQnLpiU`e(3sC)n++d$iz8Zl&q_nsq;j@kVgRYWeEc_)N|ez{$ED ziYd4GU_4kiZFSr9eBVzShyUC9=rb3nVNM@s7#`nKwRxZTX3uMZ>l>x0US zKN|_@Q_MX3=`X)qOG(2NuE3G}idY=3j zAb`F1o&z6%mt`8Z3Xi25wh7PrN`q(5)3sOl0Q^4T)8G$@eg=Gx@IBza2z~|nEE6Fq1U1sf(}uTxtFOrzPwN@s8J}~a$Gy^*Z##N`jNMc8lVnbaQWv2k*PfO`r-3?` zAz!5>N?jE`0G^aJSA+kF@U-=T;p>>M0FCsxwTzak=PmY2%x7F_`&ZztpEJVK#_xp> zz}vW`!INOCnYZyrW*v14J&*lu^dRe39cvK(Rx-Cj^{kuy%XFR_$R7gF{gI9cAAmnD zd!Uy2537-M~GvRx|PYXW|{TkFhr!~9RG)my#$`R zE6l;D`MgoET{~s4ty@kZAp%OZqovSTFWW`W*3PT4&Sx;jNnZd)esxL$U=py}tU1FU zgAV3vosA;rdgd3`*xBOfZ*_Fuz+BkTb2}V)=-AyPa>oDD4zI$4<#JI~qg+0$l&eV? zM@dpB4aTY~iN+GuwIiILYR;uN(^IiQa0G-VhN26eW&{r7G(l zjilO3eW6vaEk>DScs2Uf_C6$z8v?MG>B~{3)fog6rEDsE*Bq=KR zo0R(z$^N0Je^`a#=w3BiiVK6KC?E6><*E?%McsQig1@z)cnHow7>|xXoXh8{v5MKL zy#C8syLNIG{?=v{N{JfHYX{@-H66kFP zL1zI85|LRs=tOVR!34P}*&n2Xx8DwG;RAM63zh8$wEw|o6WL;FXZNo3E9pJEckSwM zHDP1>0ka3{+U%GjFd+*{(YY`mC$<_)rslnoRO*#<^pVZ9DHdk|l(AhvYcgO)$#r4B zHfD95ajIio*Oh%bz$1BND#__TVN^0G9El*pF{(y!92N!&rF=A|s$=1q(P%UZ4U86! z_9tTsXNNh$-Uhk2oIXOFJZ8{QunFXZk$ljJ`8KcvQ7##UX*j&p5w$ALsk9>ZOx9#_ zEYFq;FvSgXuUJ22Bps<@xir`TADDPEYN9tkgEz<0GmJLtjPrw+gShbZs61eI86`tF zjvbH4b@@IjMozW*fxvpxaY5fJG=R0X9~fekld>()ICc0$PnZplWEYet3t8*oXjAn_ zHhcWoiDTi(Ls=VauGneg6to8eMO+JlYsZhukKTSYk(9l%*~&+_RnAu;#oLmBQ2N?4 z>_~15!B47%DAo(mV9e6tD!io^T)VuPAmW{;teHh!tT|)%!X<0GTw$*sKmO{Q;g^nl z<#2f7&FtYYRAI5uAC=-rb@j#g(WsUgaAk$qbF80j1NqJ2n+e-){J8NMm&9kK7tr{d zNa7Fd3>sgzuA1i!a7KsE4b-*2X94(bwB`(+W6=j_@1ivw!n3U4%jjEAD3x(=3-Bqy zAE0kOt<<<+J-=6}3BmtDzq+W@6$dv1PYLFGR^6EKIe_dhz+CJ$x&UnpHrDi>QgaSo z4;(;`0w|%AbyLu zg7+EG|1R+U@0q&bIB^p&meA|n2VOO4>V)IOO~4Z}{yd&9n-Pag4rW|fH`G6X@um;) z4&vZvfv-832SvQk{ZQWdRFze>JU|Tm+4rbllbTH#HBiPo>J;AnaY+c#9c_8DqZW_J1Vcjv_ zx^8YE2>*qa)oz`6rB!NS< zZ2x7^NBA|_p6<~f>&LkG`wK0%vz5LNI1xuq`)U2N{RJ(5T7P4po}J0}eEM%awRZoY z<$UuqWc4kk8~k?BA_C%-Xc-6dJHsglQ-8?8w0*_FdryR`M;j;#b z`rPkP!R+|WCGTMRTX8VswGE9#F(`!`D1K<>46*-}dl5 z4?plQe?Q#)+VA<+|CBe*=Mq;x>tN=;*TWSLk9+u%hbKLJ-NQFMJnP}R9-i~CJ_zuW z`=h@t&iNt^Je>A$#=}_;_jJx2zt1fGPS#b8it-_j+`$vCb@8j*6TC|5*D?M2waB+U zJHPgN=f3LECO+E~@B)81z|o>2Q}3cI&=7+$k`vQ}l`c+`()qg$3*UQ9iiZ-!VHV*Mx=odoN|o zp-UM7J#x|{#PNUPyYP7qUh`U ze%`{|#q2LNpSmvbchi?~{9`eE$NnduO!EEtJLHSSe@^0mN#-&BT*{CCPRJIsf0GkH zS~7af)wS00~1IJ@xeujrYM+x7%bwpD$oLQ+6T5M zU>Ydm{{J)QTnCgL7JC6IE zcR0Sn6Kj9_NzquYK{t{zST5YePPIE=JIaGp?`AX zzg%^@HN3prbu=b^e@E7D>=7&w(mS}=gRW25UN|51_xgTw<5mBJ_PQVLx848meYU4z zE8TF{InaH0OZM}{Eqm^17tUAxv&VeF+JAa}*}b7Xp6mbZ{N*ZcT`q3fZeGJt{AJjG z{IM~GLEDVEysP=1^U-;~8Y&YBzg zg)`gJ&}9|jK2?AG%*CVXemmb!jz^C$sUA;6ORiAL>pBRsRPV6Ke9dz~5c(gZT z%l&o1(qLTo!{>|zZLQK{hr^0;@65>w=wa1dVXa+!}Ck0)BjA&8|*1@ zXN4z|*F(7c#B)SF^G~bHJoV-Nb7=1y|AuEFte-jFf@5W~#-+(Nkr}3u9m_y^V$c!bgLuSs1`i966i`5wO6OkiuBOAxKK7&6Gi_E@uI=O-C8{uO5q_&)u*}^;5 zV>Fh&4?xy&9u}E>bc-B8R=;&YWp-hrQjz%le9XNZ3WIg*a6Aq&QIwsUPI*}d@! z!VdNBF0grf1vE~(&uML#ejf5)VB=t)G;$CA5HkJ4^q$BO*7CTkV|oHU1IAuz2sm?N7p83(o}G`+__BDMQ8d5Y-2y>a8J)1v-9_wuN zk?7gxK2Oega)&2(ii{&-j(W15F}CwfuivM{hWc+qkKFeAMbUGw?f2wEp4{!pCO%y% zjhjl6%g5EERx1=os;0A+)N7{mXtgqQG+(U7!`JeiLncBhl;f+p@yR4Q6%Ce$;_sGSC5$@SPNI!?MqhBN!Lp^oU+t44LAwdP#|JL!&lg^Zyr$>~h%-fE(E$cdeL)uo=jq4`ZjfZTJ^T1ErH^;Vq5`@-wyc@(x zyHRu+=}u9_|9z|Y)4^tmUVSx6yAC$b`rg>5;pJF&PuJ63Cyw{@bf|y0|NM;rTWPSv zMJRW+qGEC_&R1))Pi?rWX4Xd|{DF{c{t!;t&eYZ`cPC-P zP@ID&@;K79oc$9gMMv>O0u=&#C8;7T4;PC0q;4wp_{wB5nZyp0jip1ix;z$R^z@Xqto%hyj@bqq8!&%#qg!<8wQMK@ENoiPTnbbxR(mL+a+emk} z8f$9JpN(`hvZdGo+B)*=D7{=OMY2JwnX?!A;{N#g{terc8`;|79zfIQ`}@zoc;UtP z;+cMRHd{QTeu~dKdCJD=;zL#f9~b@rRRU;Y<%J6 z{zQmiJXbFhlXFMEbNDYn=b(F^frNKJTzoHK-7~nD_|C&;;MSTkd=4d-aW&P^hVTvK zE%=i~{wA&;;(BPsm_FpRXZd#g84&&u`L1bWZVI2qgLL!j#z1B5Uk10%x_2Bl{??c!k6XaY9v=j+c>FYY)#Fa^n#avxzLc~7e?-0; z>(MIw-^g25-M1l@llOrmue=+~cX%z&fU_PqgZqR}U@z_YFZe?kP1-adY}X6F@^J56 zf$eYO+L6Wj3jYnb`3!!e5&l>3t_$#w$F1Nc;h(VnuVVgw{Q=Bh_*^4igvPN)VaNW+ zJHQ!XJD$2b11!Iaax;bbNclXt`3Cl@$DadF3+wn7gmwIj!aDvXk2(I^!aDvY^uzvb zeT=_VSjQiE%<=aM{|Nbxx3C@_H-r0xx8nDVJ#XW829I07mptbA7!YoU{i928f$&S< z);l<#!Xx15UCdwjcfmWCv3|ne0e|K%v3|nVzt|s)Ex!QQGCc{o7ni}hH3`20Zp}k4 z{3f`06!Q{Z0C&9V;t1>in2dGnm-05eXrFo=^YEB)1WnlfuA{tV&czwlCpUwqrTi`M zj_(^Y<8d>1R`>_B=e#`L4W9Ry~E<~(NHnfI7+$B$EtJIh`<b zxBq*w=U(&E8-QKjdM!)gd&qb4mrhBam;BF>@6<0gkagz&VD(L6wXTn?{z0SlHGjeC zx25&9ondK5S*Mr5_&H7;A7dKR2=bi%p&UlzrZlym!XUcJIPo|9I;E&VQ{ z{r{rXH>ESy_Df>FUD{FmYhtgSP_gil^Sz-D7ZaK98w0qQ z$a`^>2}tBNT(h{C$o!^r8yAzZ6@+bnED&aYS&!Kt?}bdv*x#tf>~F?n_NQ@$YTjdb z?_y%c_9AhJvhEi$zX45o<$Qmd_n6-=cyC}*`xTEP=$m9cS-uZfpU12}>M_eT9%*~C zUOD?;@|g8+d(82!dK`fxSy=6Vz+=|eeaiCvxadPB@&Q~61SB&3xk5l9bG_~mkjPxG zHVR10=-)1nxn5b1*;mZPk{`#^0yW(GF9N)6XoZtH%+x|Redj`+iOjL9JZNlu9{@w2} z{nzC&=ktum5xCFecJKv{4}u3gJ^-!@v;PdPX^*+yZ+Oi0ob#CeecR*x;KR6>*dBd) zoPebKjKh@A1bo5char!8%=YR5-wb#r;JJVo0$vJuCEz;&uLa!1K_Th<^gJl@KIWJA z2HY3$rGQ5Rt_OTG;F*Bu0$vDsDd3fW?*zOSaFd)Doo`#fnSgr(?hE)*z@q`z1HKvX zOu%yiF9f_4@JhgU0$vNaN#a8~|9~}qXt_QQDE9^SeAf5(i^dzZuLtEf1D*+ZF5rcL zmjYf1_)frU0XK1ANIJf@fHMK>bGF*|1?Bur!*6di;CjF}1D*+(?||AK5-z+C}n1MUxaAmDPqQvpv0JR9(Qz>5Jd2Yfr=)qwFTv0=Wg0Y?FM1)L4I zKj49Y%K=XXJRR_C!1Dnw2D}{b?SNMU)(1#k@7AV`{)htZ3OE}u|96T>+ZzbD9Pm`Y z(*YlO{E5f*Wey!Y+Iei_=jjb!cGB-Xot0!<{xgQs=?h=#|;Oh*nh;KP5MzP_}?6@ntoLBKQL*bd?53>E#UhtM$PZI(CVDhPWZKr z1?dOh&hhc96S4hYk;2IO_bHa^r>+fNQe`Lp!=siPG@aG!6E>HxSniwFT_5VKPvJ>( z?)2&7@z1-g+vYuSFR?p*TC^va_ry;*JSKU!&Aa2{R3D3bn02|VCq5>&l6Tv@JMQkL z9(kABP(S70=kc5)x4I22Jf4>9Wz4~91eiwtuX?@qaG5-SLUj0W0okhRbKbzG0 z+CP7*rinZ*xk~uEAck$J#tAa4RC@yw>j%R$%Tm9bS~?-D{~FXZ4eMv+r=#^>LhfBZ zC*$9&emJj?qfP3cmj1)>a;|=|zrFejk^7|nnElVdb%U_IIvTv!`js=TPOEgt_2P4h zxHbL#hub_H6KlRL3pcA3s6-0;=l?$5tN*DEYo31FX8-)(X>CsT&rWpyz`2k4v)dG| j^(5zyWA;-x)?b1mToc+~+UIKNb1v)g|E0Bm-IxCZwP{$s diff --git a/client/internal/ebpf/ebpf/dns_fwd_linux.go b/client/internal/ebpf/ebpf/dns_fwd_linux.go deleted file mode 100644 index 1e7774573..000000000 --- a/client/internal/ebpf/ebpf/dns_fwd_linux.go +++ /dev/null @@ -1,52 +0,0 @@ -package ebpf - -import ( - "encoding/binary" - "fmt" - "net/netip" - - log "github.com/sirupsen/logrus" -) - -const ( - mapKeyDNSIP uint32 = 0 - mapKeyDNSPort uint32 = 1 -) - -func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error { - log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort) - tf.lock.Lock() - defer tf.lock.Unlock() - - err := tf.loadXdp() - if err != nil { - return err - } - - if !ip.Is4() { - return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip) - } - ip4 := ip.As4() - err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:])) - if err != nil { - return err - } - - err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort)) - if err != nil { - return err - } - - tf.setFeatureFlag(featureFlagDnsForwarder) - err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags) - if err != nil { - return err - } - return nil -} - -func (tf *GeneralManager) FreeDNSFwd() error { - log.Debugf("free ebpf DNS forwarder") - return tf.unsetFeatureFlag(featureFlagDnsForwarder) -} - diff --git a/client/internal/ebpf/ebpf/manager_linux.go b/client/internal/ebpf/ebpf/manager_linux.go index 7520a6387..a13f5f19a 100644 --- a/client/internal/ebpf/ebpf/manager_linux.go +++ b/client/internal/ebpf/ebpf/manager_linux.go @@ -15,8 +15,7 @@ import ( const ( mapKeyFeatures uint32 = 0 - featureFlagWGProxy = 0b00000001 - featureFlagDnsForwarder = 0b00000010 + featureFlagWGProxy = 0b00000001 ) var ( @@ -28,9 +27,9 @@ var ( // GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c // The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel. -// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., dns_fwd.c and wg_proxy.c). +// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., wg_proxy.c). // -//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include +//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include -include src/bpf_map_def.h type GeneralManager struct { lock sync.Mutex link link.Link diff --git a/client/internal/ebpf/ebpf/manager_linux_test.go b/client/internal/ebpf/ebpf/manager_linux_test.go index 5664a4565..e09fcb977 100644 --- a/client/internal/ebpf/ebpf/manager_linux_test.go +++ b/client/internal/ebpf/ebpf/manager_linux_test.go @@ -7,33 +7,24 @@ import ( func TestManager_setFeatureFlag(t *testing.T) { mgr := GeneralManager{} mgr.setFeatureFlag(featureFlagWGProxy) - if mgr.featureFlags != 1 { + if mgr.featureFlags != featureFlagWGProxy { t.Errorf("invalid feature state") } - mgr.setFeatureFlag(featureFlagDnsForwarder) - if mgr.featureFlags != 3 { - t.Errorf("invalid feature state") + mgr.setFeatureFlag(featureFlagWGProxy) + if mgr.featureFlags != featureFlagWGProxy { + t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags) } } func TestManager_unsetFeatureFlag(t *testing.T) { mgr := GeneralManager{} mgr.setFeatureFlag(featureFlagWGProxy) - mgr.setFeatureFlag(featureFlagDnsForwarder) err := mgr.unsetFeatureFlag(featureFlagWGProxy) if err != nil { t.Errorf("unexpected error: %s", err) } - if mgr.featureFlags != 2 { - t.Errorf("invalid feature state, expected: %d, got: %d", 2, mgr.featureFlags) - } - - err = mgr.unsetFeatureFlag(featureFlagDnsForwarder) - if err != nil { - t.Errorf("unexpected error: %s", err) - } if mgr.featureFlags != 0 { t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags) } diff --git a/client/internal/ebpf/ebpf/src/bpf_map_def.h b/client/internal/ebpf/ebpf/src/bpf_map_def.h new file mode 100644 index 000000000..9528fb592 --- /dev/null +++ b/client/internal/ebpf/ebpf/src/bpf_map_def.h @@ -0,0 +1,16 @@ +// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy +// map definitions: they load on kernels built without BTF, which BTF-style +// (SEC(".maps")) definitions do not. Define the struct ourselves so the +// programs compile against current libbpf headers. +#ifndef NB_BPF_MAP_DEF_H +#define NB_BPF_MAP_DEF_H + +struct bpf_map_def { + unsigned int type; + unsigned int key_size; + unsigned int value_size; + unsigned int max_entries; + unsigned int map_flags; +}; + +#endif diff --git a/client/internal/ebpf/ebpf/src/dns_fwd.c b/client/internal/ebpf/ebpf/src/dns_fwd.c deleted file mode 100644 index 9f8de2001..000000000 --- a/client/internal/ebpf/ebpf/src/dns_fwd.c +++ /dev/null @@ -1,67 +0,0 @@ -const __u32 map_key_dns_ip = 0; -const __u32 map_key_dns_port = 1; - -struct bpf_map_def SEC("maps") nb_map_dns_ip = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u32), - .max_entries = 10, -}; - -struct bpf_map_def SEC("maps") nb_map_dns_port = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u16), - .max_entries = 10, -}; - -__be32 dns_ip = 0; -__be16 dns_port = 0; - -// 13568 is 53 in big endian -__be16 GENERAL_DNS_PORT = 13568; - -bool read_settings() { - __u16 *port_value; - __u32 *ip_value; - - // read dns ip - ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip); - if(!ip_value) { - return false; - } - dns_ip = htonl(*ip_value); - - // read dns port - port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port); - if (!port_value) { - return false; - } - dns_port = htons(*port_value); - return true; -} - -int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) { - if (dns_port == 0) { - if(!read_settings()){ - return XDP_PASS; - } - // bpf_printk("dns port: %d", ntohs(dns_port)); - // bpf_printk("dns ip: %d", ntohl(dns_ip)); - } - - if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) { - udp->dest = dns_port; - // Clear the now-stale checksum; zero means "not computed" for IPv4. - udp->check = 0; - return XDP_PASS; - } - - if (udp->source == dns_port && ip->saddr == dns_ip) { - udp->source = GENERAL_DNS_PORT; - udp->check = 0; - return XDP_PASS; - } - - return XDP_PASS; -} diff --git a/client/internal/ebpf/ebpf/src/prog.c b/client/internal/ebpf/ebpf/src/prog.c index f32103f28..44ee53458 100644 --- a/client/internal/ebpf/ebpf/src/prog.c +++ b/client/internal/ebpf/ebpf/src/prog.c @@ -5,11 +5,9 @@ #include #include #include -#include "dns_fwd.c" #include "wg_proxy.c" const __u16 flag_feature_wg_proxy = 0b01; -const __u16 flag_feature_dns_fwd = 0b10; const __u32 map_key_features = 0; struct bpf_map_def SEC("maps") nb_features = { @@ -48,10 +46,6 @@ int nb_xdp_prog(struct xdp_md *ctx) { return XDP_PASS; } - if (*features & flag_feature_dns_fwd) { - xdp_dns_fwd(ip, udp); - } - if (*features & flag_feature_wg_proxy) { xdp_wg_proxy(ip, udp); } diff --git a/client/internal/ebpf/ebpf/src/readme.md b/client/internal/ebpf/ebpf/src/readme.md index 0ab393dd4..aa47847da 100644 --- a/client/internal/ebpf/ebpf/src/readme.md +++ b/client/internal/ebpf/ebpf/src/readme.md @@ -1,8 +1,18 @@ -# DNS forwarder +# XDP programs -The agent attach the XDP program to the lo device. We can not use fake address in eBPF because the -traffic does not appear in the eBPF program. The program capture the traffic on wg_ip:53 and -overwrite in it the destination port to 5053. +`prog.c` is attached to the `lo` device and dispatches to the features enabled in the +`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites +loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy +port instead, and swaps the peer endpoint port into the source so the proxy can tell +peers apart. + +Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf +1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`) +definitions do not. + +Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs +`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link` +(kernel >= 5.7), and only one XDP program can own `lo` at a time. # Debug diff --git a/client/internal/ebpf/manager/manager.go b/client/internal/ebpf/manager/manager.go index 25a767090..fdc5d8d82 100644 --- a/client/internal/ebpf/manager/manager.go +++ b/client/internal/ebpf/manager/manager.go @@ -1,11 +1,7 @@ package manager -import "net/netip" - -// Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy +// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy type Manager interface { - LoadDNSFwd(ip netip.Addr, dnsPort int) error - FreeDNSFwd() error LoadWgProxy(proxyPort, wgPort int) error FreeWGProxy() error } From 269cbadfeb43a611423d461b666b65973d67be56 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:44:19 +0200 Subject: [PATCH 21/63] [management] expire and disconnect peers while including offline peers (#7467) --- management/server/account.go | 7 +- management/server/account_test.go | 179 +++++++++++++++++++++++++++- management/server/peer.go | 16 ++- management/server/scheduler.go | 9 +- management/server/scheduler_test.go | 89 ++++++++++++++ management/server/types/account.go | 5 +- management/server/user.go | 77 +++++++++--- 7 files changed, 350 insertions(+), 32 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 3ceef79db..6ccf673f5 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -719,8 +719,10 @@ func (am *DefaultAccountManager) schedulePeerLoginExpiration(ctx context.Context log.WithContext(ctx).Tracef("peer login expiration job for account %s is already scheduled", accountID) return } + // The job outlives the request that arms it, so it must not inherit the request's cancellation. + jobCtx := context.WithoutCancel(ctx) if nextRun, ok := am.getNextPeerExpiration(ctx, accountID); ok { - go am.peerLoginExpiry.Schedule(ctx, nextRun, accountID, am.peerLoginExpirationJob(ctx, accountID)) + go am.peerLoginExpiry.Schedule(jobCtx, nextRun, accountID, am.peerLoginExpirationJob(jobCtx, accountID)) } } @@ -752,8 +754,9 @@ func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context // checkAndSchedulePeerInactivityExpiration periodically checks for inactive peers to end their sessions func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) { am.peerInactivityExpiry.Cancel(ctx, []string{accountID}) + jobCtx := context.WithoutCancel(ctx) if nextRun, ok := am.getNextInactivePeerExpiration(ctx, accountID); ok { - go am.peerInactivityExpiry.Schedule(ctx, nextRun, accountID, am.peerInactivityExpirationJob(ctx, accountID)) + go am.peerInactivityExpiry.Schedule(jobCtx, nextRun, accountID, am.peerInactivityExpirationJob(jobCtx, accountID)) } } diff --git a/management/server/account_test.go b/management/server/account_test.go index b462cc2a6..bd7bf2d97 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -1920,6 +1920,154 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. } } +func TestDefaultAccountManager_SchedulePeerLoginExpiration_IncludesOfflinePeers(t *testing.T) { + manager, updateManager, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + connectedKey, offlineKey := addExpiringPeers(t, manager) + _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err, "expecting to update account settings successfully but got error") + manager.peerLoginExpiry.CancelAll(context.Background()) + + // The connected peer logged in just now, so a job computed from connected peers alone + // would be armed for an hour. The offline peer's login expires in two seconds; a + // reconnect of that peer must not have to wait for the connected peer's tick. + now := time.Now().UTC() + setPeerLogin(t, manager, accountID, connectedKey, true, now) + setPeerLogin(t, manager, accountID, offlineKey, false, now.Add(-time.Hour+2*time.Second)) + + offlinePeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, offlineKey) + require.NoError(t, err) + updateManager.CreateChannel(context.Background(), offlinePeer.ID) + + manager.peerLoginExpiry = NewDefaultScheduler() + t.Cleanup(func() { manager.peerLoginExpiry.CancelAll(context.Background()) }) + manager.schedulePeerLoginExpiration(context.Background(), accountID) + + // The flag is committed per peer before the disconnect fans out, so wait for both. + require.Eventually(t, func() bool { + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, offlineKey) + return err == nil && peer.Status.LoginExpired && !updateManager.HasChannel(offlinePeer.ID) + }, 10*time.Second, 100*time.Millisecond, "offline peer should be expired and disconnected at its own deadline") + + connectedPeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, connectedKey) + require.NoError(t, err) + assert.False(t, connectedPeer.Status.LoginExpired, "connected peer with a fresh login must not expire") +} + +func TestDefaultAccountManager_SchedulePeerLoginExpiration_DetachesRequestContext(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + connectedKey, _ := addExpiringPeers(t, manager) + setPeerLogin(t, manager, accountID, connectedKey, true, time.Now().UTC()) + + scheduled := make(chan context.Context, 1) + manager.peerLoginExpiry = &MockScheduler{ + IsSchedulerRunningFunc: func(string) bool { return false }, + ScheduleFunc: func(ctx context.Context, _ time.Duration, _ string, _ func() (time.Duration, bool)) { + scheduled <- ctx + }, + } + + requestCtx, cancel := context.WithCancel(context.Background()) + manager.schedulePeerLoginExpiration(requestCtx, accountID) + cancel() + + select { + case jobCtx := <-scheduled: + assert.NoError(t, jobCtx.Err(), "the expiration job must outlive the request that armed it") + case <-time.After(time.Second): + t.Fatal("timeout while waiting for the job to be scheduled") + } +} + +func TestDefaultAccountManager_ExpireAndUpdatePeers_SkipsPeerThatLoggedInAgain(t *testing.T) { + manager, updateManager, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + reloggedKey, staleKey := addExpiringPeers(t, manager) + _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err, "expecting to update account settings successfully but got error") + manager.peerLoginExpiry.CancelAll(context.Background()) + + expiredLogin := time.Now().UTC().Add(-2 * time.Hour) + setPeerLogin(t, manager, accountID, reloggedKey, true, expiredLogin) + setPeerLogin(t, manager, accountID, staleKey, true, expiredLogin) + + expiredPeers, err := manager.getExpiredPeers(context.Background(), accountID) + require.NoError(t, err) + require.Len(t, expiredPeers, 2, "both peers should be due for expiration") + + // The job holds the candidate list while one peer completes a fresh login, which + // moves its deadline into the future and must win over the stale candidate entry. + setPeerLogin(t, manager, accountID, reloggedKey, true, time.Now().UTC()) + + reloggedPeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, reloggedKey) + require.NoError(t, err) + stalePeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, staleKey) + require.NoError(t, err) + updateManager.CreateChannel(context.Background(), reloggedPeer.ID) + updateManager.CreateChannel(context.Background(), stalePeer.ID) + + err = manager.expireAndUpdatePeers(context.Background(), accountID, expiredPeers, peerExpirationSessionExpired) + require.NoError(t, err) + + reloggedPeer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, reloggedKey) + require.NoError(t, err) + assert.False(t, reloggedPeer.Status.LoginExpired, "a peer that logged in again must not be flagged from the stale candidate list") + assert.True(t, reloggedPeer.Status.Connected, "the re-logged peer must keep its connected status") + assert.True(t, updateManager.HasChannel(reloggedPeer.ID), "the re-logged peer's update channel must stay open") + + stalePeer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, staleKey) + require.NoError(t, err) + assert.True(t, stalePeer.Status.LoginExpired, "a peer that is still due must be flagged") + assert.False(t, updateManager.HasChannel(stalePeer.ID), "the expired peer's update channel must be closed") +} + +// addExpiringPeers registers two SSO peers with login expiration enabled and returns their public keys. +func addExpiringPeers(t *testing.T, manager *DefaultAccountManager) (string, string) { + t.Helper() + keys := make([]string, 0, 2) + for _, hostname := range []string{"connected-peer", "offline-peer"} { + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: hostname}, + LoginExpirationEnabled: true, + }, false) + require.NoError(t, err, "unable to add peer") + keys = append(keys, key.PublicKey().String()) + } + return keys[0], keys[1] +} + +func setPeerLogin(t *testing.T, manager *DefaultAccountManager, accountID, peerKey string, connected bool, lastLogin time.Time) { + t.Helper() + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerKey) + require.NoError(t, err) + peer.Status.Connected = connected + peer.LastLogin = &lastLogin + require.NoError(t, manager.Store.SavePeer(context.Background(), accountID, peer)) +} + func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") @@ -2702,7 +2850,7 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) { expectedNextExpiration: time.Duration(0), }, { - name: "No connected peers, no expiration", + name: "Offline peer with expiration, return expiration", peers: map[string]*nbpeer.Peer{ "peer-1": { Status: &nbpeer.PeerStatus{ @@ -2721,8 +2869,33 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) { }, expiration: time.Second, expirationEnabled: false, - expectedNextRun: false, - expectedNextExpiration: time.Duration(0), + expectedNextRun: true, + expectedNextExpiration: time.Second, + }, + { + name: "Offline peer with the earliest deadline defines the next run", + peers: map[string]*nbpeer.Peer{ + "peer-1": { + Status: &nbpeer.PeerStatus{ + Connected: true, + }, + LoginExpirationEnabled: true, + LastLogin: util.ToPtr(time.Now().UTC()), + UserID: userID, + }, + "peer-2": { + Status: &nbpeer.PeerStatus{ + Connected: false, + }, + LoginExpirationEnabled: true, + LastLogin: util.ToPtr(time.Now().UTC().Add(-50 * time.Minute)), + UserID: userID, + }, + }, + expiration: time.Hour, + expirationEnabled: true, + expectedNextRun: true, + expectedNextExpiration: 10 * time.Minute, }, { name: "Connected peers with disabled expiration, no expiration", diff --git a/management/server/peer.go b/management/server/peer.go index 07619f51e..9f5572252 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1494,9 +1494,12 @@ func checkAuth(ctx context.Context, loginUserID string, peer *nbpeer.Peer) error func peerLoginExpired(ctx context.Context, peer *nbpeer.Peer, settings *types.Settings) bool { expired, expiresIn := peer.LoginExpired(settings.PeerLoginExpiration) - expired = settings.PeerLoginExpirationEnabled && expired - if expired || peer.Status.LoginExpired { - log.WithContext(ctx).Debugf("peer's %s login expired %v ago", peer.ID, expiresIn) + if settings.PeerLoginExpirationEnabled && expired { + log.WithContext(ctx).Debugf("peer's %s login expired %v ago", peer.ID, -expiresIn) + return true + } + if peer.Status.LoginExpired { + log.WithContext(ctx).Debugf("peer's %s login is marked as expired", peer.ID) return true } return false @@ -1643,7 +1646,9 @@ func (am *DefaultAccountManager) UpdateAccountPeer(ctx context.Context, accountI // getNextPeerExpiration returns the minimum duration in which the next peer of the account will expire if it was found. // If there is no peer that expires this function returns false and a duration of 0. -// This function only considers peers that haven't been expired yet and that are connected. +// This function only considers peers that haven't been expired yet. Offline peers count too: +// a running job is never re-armed on connect, so a peer that reconnects with an old login +// must already be part of the scheduled run. func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, accountID string) (time.Duration, bool) { peersWithExpiry, err := am.Store.GetAccountPeersWithExpiration(ctx, store.LockingStrengthNone, accountID) if err != nil { @@ -1663,8 +1668,7 @@ func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, acco var nextExpiry *time.Duration for _, peer := range peersWithExpiry { - // consider only connected peers because others will require login on connecting to the management server - if peer.Status.LoginExpired || !peer.Status.Connected { + if peer.Status.LoginExpired { continue } _, duration := peer.LoginExpired(settings.PeerLoginExpiration) diff --git a/management/server/scheduler.go b/management/server/scheduler.go index b61643295..1daea4295 100644 --- a/management/server/scheduler.go +++ b/management/server/scheduler.go @@ -117,6 +117,7 @@ func (wm *DefaultScheduler) Schedule(ctx context.Context, in time.Duration, ID s } ticker := time.NewTicker(in) + period := in wm.jobs[ID] = cancel log.WithContext(ctx).Debugf("scheduled a job %s to run in %s. There are %d total jobs scheduled.", ID, in.String(), len(wm.jobs)) @@ -136,14 +137,18 @@ func (wm *DefaultScheduler) Schedule(ctx context.Context, in time.Duration, ID s if !reschedule { wm.mu.Lock() defer wm.mu.Unlock() - delete(wm.jobs, ID) + // A Cancel during job() may have registered a replacement under this ID. + if current, ok := wm.jobs[ID]; ok && current == cancel { + delete(wm.jobs, ID) + } log.WithContext(ctx).Debugf("job %s is not scheduled to run again", ID) ticker.Stop() return } // we need this comparison to avoid resetting the ticker with the same duration and missing the current elapsesed time - if runIn != in { + if runIn != period { ticker.Reset(runIn) + period = runIn } case <-cancel: log.WithContext(ctx).Debugf("job %s was canceled, stopping timer", ID) diff --git a/management/server/scheduler_test.go b/management/server/scheduler_test.go index e3af551ad..9dd13ce6b 100644 --- a/management/server/scheduler_test.go +++ b/management/server/scheduler_test.go @@ -6,10 +6,12 @@ import ( "math/rand" "runtime" "sync" + "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestScheduler_Performance(t *testing.T) { @@ -150,3 +152,90 @@ func TestScheduler_Schedule(t *testing.T) { scheduler.cancel(context.Background(), jobID) } + +func TestScheduler_Schedule_ResetsTickerAfterReturningInitialInterval(t *testing.T) { + jobID := "test-scheduler-job-2" + scheduler := NewDefaultScheduler() + defer scheduler.Cancel(context.Background(), []string{jobID}) + + initial := 30 * time.Millisecond + stretched := 400 * time.Millisecond + runs := make(chan time.Time, 3) + count := 0 + // The first run stretches the period; the second returns the initial interval again, + // which must shrink the period back instead of keeping the stretched one. + job := func() (nextRunIn time.Duration, reschedule bool) { + count++ + runs <- time.Now() + switch count { + case 1: + return stretched, true + case 2: + return initial, true + default: + return 0, false + } + } + scheduler.Schedule(context.Background(), initial, jobID, job) + + var stamps []time.Time + for len(stamps) < 3 { + select { + case ts := <-runs: + stamps = append(stamps, ts) + case <-time.After(2 * time.Second): + t.Fatalf("timed out after %d runs", len(stamps)) + } + } + assert.Less(t, stamps[2].Sub(stamps[1]), stretched/2, "returning the initial interval must reset the stretched ticker") +} + +func TestScheduler_Schedule_StaleCompletionKeepsReplacement(t *testing.T) { + jobID := "test-scheduler-job-3" + scheduler := NewDefaultScheduler() + defer scheduler.Cancel(context.Background(), []string{jobID}) + + started := make(chan struct{}) + release := make(chan struct{}) + staleJob := func() (nextRunIn time.Duration, reschedule bool) { + close(started) + <-release + return 0, false + } + scheduler.Schedule(context.Background(), 10*time.Millisecond, jobID, staleJob) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for the first job to start") + } + + // Cancel the job while it is still executing and register a replacement under the + // same ID, as the expiration paths do on a settings change. + scheduler.Cancel(context.Background(), []string{jobID}) + var replacementRuns atomic.Int32 + scheduler.Schedule(context.Background(), 20*time.Millisecond, jobID, func() (nextRunIn time.Duration, reschedule bool) { + replacementRuns.Add(1) + return 20 * time.Millisecond, true + }) + require.True(t, scheduler.IsSchedulerRunning(jobID), "replacement must be registered") + + // The stale job now completes without rescheduling; its cleanup must leave the + // replacement's entry in place. + close(release) + assert.Never(t, func() bool { return !scheduler.IsSchedulerRunning(jobID) }, 200*time.Millisecond, 10*time.Millisecond, + "stale completion must not drop the replacement job") + + var duplicateRuns atomic.Int32 + scheduler.Schedule(context.Background(), 10*time.Millisecond, jobID, func() (nextRunIn time.Duration, reschedule bool) { + duplicateRuns.Add(1) + return 10 * time.Millisecond, true + }) + assert.Never(t, func() bool { return duplicateRuns.Load() > 0 }, 100*time.Millisecond, 10*time.Millisecond, + "a duplicate schedule must be refused while the replacement is registered") + + scheduler.Cancel(context.Background(), []string{jobID}) + assert.False(t, scheduler.IsSchedulerRunning(jobID), "cancel must find and remove the replacement") + runsAfterCancel := replacementRuns.Load() + assert.Never(t, func() bool { return replacementRuns.Load() > runsAfterCancel+1 }, 150*time.Millisecond, 10*time.Millisecond, + "the replacement must stop after cancel") +} diff --git a/management/server/types/account.go b/management/server/types/account.go index d689b0175..d0688d1ee 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -404,7 +404,7 @@ func (a *Account) GetExpiredPeers() []*nbpeer.Peer { // GetNextPeerExpiration returns the minimum duration in which the next peer of the account will expire if it was found. // If there is no peer that expires this function returns false and a duration of 0. -// This function only considers peers that haven't been expired yet and that are connected. +// This function only considers peers that haven't been expired yet, whether connected or not. func (a *Account) GetNextPeerExpiration() (time.Duration, bool) { peersWithExpiry := a.GetPeersWithExpiration() if len(peersWithExpiry) == 0 { @@ -412,8 +412,7 @@ func (a *Account) GetNextPeerExpiration() (time.Duration, bool) { } var nextExpiry *time.Duration for _, peer := range peersWithExpiry { - // consider only connected peers because others will require login on connecting to the management server - if peer.Status.LoginExpired || !peer.Status.Connected { + if peer.Status.LoginExpired { continue } _, duration := peer.LoginExpired(a.Settings.PeerLoginExpiration) diff --git a/management/server/user.go b/management/server/user.go index 0a711389a..823c1b2e4 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -1177,28 +1177,35 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou dnsDomain := am.networkMapController.GetDNSDomain(settings) var peerIDs []string - for _, peer := range peers { + defer func() { + if len(peerIDs) == 0 { + return + } + // this will trigger peer disconnect from the management service + log.Debugf("Expiring %d peers for account %s", len(peerIDs), accountID) + am.networkMapController.DisconnectPeers(ctx, accountID, peerIDs) + }() + for _, candidate := range peers { // nolint:staticcheck - ctx = context.WithValue(ctx, nbcontext.PeerIDKey, peer.Key) + peerCtx := context.WithValue(ctx, nbcontext.PeerIDKey, candidate.Key) - if peer.UserID == "" { + if candidate.UserID == "" { // we do not want to expire peers that are added via setup key continue } - if peer.Status.LoginExpired { + peer, err := am.expirePeerIfStillDue(peerCtx, accountID, candidate.ID, settings, reason) + if err != nil { + return err + } + if peer == nil { continue } peerIDs = append(peerIDs, peer.ID) - peer.MarkLoginExpired(true) - - if err := am.Store.SavePeerStatus(ctx, accountID, peer.ID, *peer.Status); err != nil { - return err - } meta := peer.EventMeta(dnsDomain) meta["reason"] = string(reason) am.StoreEvent( - ctx, + peerCtx, peer.UserID, peer.ID, accountID, activity.PeerLoginExpired, meta, ) @@ -1215,15 +1222,53 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou if err != nil { return fmt.Errorf("notify network map controller of peer update: %w", err) } - - if len(peerIDs) != 0 { - // this will trigger peer disconnect from the management service - log.Debugf("Expiring %d peers for account %s", len(peerIDs), accountID) - am.networkMapController.DisconnectPeers(ctx, accountID, peerIDs) - } return nil } +// expirePeerIfStillDue flags the peer as login-expired and returns its fresh copy, or nil +// when it no longer qualifies. The candidate list is read without a lock, so a login that +// landed in between would otherwise be overwritten with a stale expired status. +func (am *DefaultAccountManager) expirePeerIfStillDue(ctx context.Context, accountID, peerID string, settings *types.Settings, reason peerExpirationReason) (*nbpeer.Peer, error) { + var expired *nbpeer.Peer + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID) + if err != nil { + if s, ok := status.FromError(err); ok && s.Type() == status.NotFound { + return nil + } + return err + } + if peer.Status.LoginExpired || !peerExpirationDue(peer, settings, reason) { + return nil + } + peer.MarkLoginExpired(true) + if err := transaction.SavePeerStatus(ctx, accountID, peer.ID, *peer.Status); err != nil { + return err + } + expired = peer + return nil + }) + if err != nil { + return nil, err + } + return expired, nil +} + +// peerExpirationDue re-evaluates a time-based expiry against the peer's current state. +// Administrative reasons expire the peer unconditionally. +func peerExpirationDue(peer *nbpeer.Peer, settings *types.Settings, reason peerExpirationReason) bool { + switch reason { + case peerExpirationSessionExpired: + expired, _ := peer.LoginExpired(settings.PeerLoginExpiration) + return settings.PeerLoginExpirationEnabled && expired + case peerExpirationInactivity: + expired, _ := peer.SessionExpired(settings.PeerInactivityExpiration) + return settings.PeerInactivityExpirationEnabled && expired + default: + return true + } +} + func (am *DefaultAccountManager) deleteUserFromIDP(ctx context.Context, targetUserID, accountID string) error { if am.userDeleteFromIDPEnabled { log.WithContext(ctx).Debugf("user %s deleted from IdP", targetUserID) From 27991aab984e5aa8fc19a307b13ea4ed0f1dc6e4 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Wed, 9 Sep 2026 15:36:37 +0200 Subject: [PATCH 22/63] [management] Let embedding binaries extend the command tree (#7483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The management binary is embedded by downstream builds that override server construction via SetNewServer, but the cobra command tree itself was closed: rootCmd is unexported and fully assembled in init, with no way to attach additional subcommands. Customize hands the built root command to a caller-supplied function before Execute, so an embedding binary can add its own commands next to — or under — the built-in ones, such as extra administrative helpers beneath the existing admin group. --- management/cmd/root.go | 9 ++++++++ management/cmd/root_test.go | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 management/cmd/root_test.go diff --git a/management/cmd/root.go b/management/cmd/root.go index 969dd60dd..ae03a09e8 100644 --- a/management/cmd/root.go +++ b/management/cmd/root.go @@ -54,6 +54,15 @@ func Execute() error { return rootCmd.Execute() } +// Customize hands the fully built root command to fn so an embedding binary +// can extend or adjust the command tree — most commonly attaching its own +// subcommands next to (or under) the built-in ones — before calling Execute. +// The root command is constructed in this package's init, so Customize may be +// called from the embedding binary's main at any point before Execute. +func Customize(fn func(root *cobra.Command)) { + fn(rootCmd) +} + func init() { mgmtCmd.Flags().IntVar(&mgmtPort, "port", 80, "server port to listen on (defaults to 443 if TLS is enabled, 80 otherwise") mgmtCmd.Flags().BoolVar(&disableLegacyManagementPort, "disable-legacy-port", false, "disabling the old legacy port (33073)") diff --git a/management/cmd/root_test.go b/management/cmd/root_test.go new file mode 100644 index 000000000..826fd2d50 --- /dev/null +++ b/management/cmd/root_test.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// TestCustomize verifies an embedding binary can extend the command tree: a +// top-level command attached through the hook, and a subcommand attached under +// the built-in admin group, are both resolvable exactly as Execute would +// resolve them. +func TestCustomize(t *testing.T) { + topLevel := &cobra.Command{Use: "some-extra", RunE: func(*cobra.Command, []string) error { return nil }} + nested := &cobra.Command{Use: "cluster", RunE: func(*cobra.Command, []string) error { return nil }} + + Customize(func(root *cobra.Command) { + root.AddCommand(topLevel) + for _, c := range root.Commands() { + if c.Name() == "admin" { + c.AddCommand(nested) + return + } + } + t.Fatal("admin command not found in the root tree") + }) + t.Cleanup(func() { + rootCmd.RemoveCommand(topLevel) + for _, c := range rootCmd.Commands() { + if c.Name() == "admin" { + c.RemoveCommand(nested) + } + } + }) + + if found, _, err := rootCmd.Find([]string{"some-extra"}); err != nil || found != topLevel { + t.Fatalf("top-level command not resolvable: found=%v err=%v", found, err) + } + if found, _, err := rootCmd.Find([]string{"admin", "cluster"}); err != nil || found != nested { + t.Fatalf("nested admin subcommand not resolvable: found=%v err=%v", found, err) + } +} From 21b4a83cea05cb2a3a54d2357c875f85ec2dd10f Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 10 Sep 2026 11:57:14 +0200 Subject: [PATCH 23/63] [management] Refuse services on unvalidated custom domains (#7341) Require validated custom domains when creating or updating reverse proxy services. Propagate validation errors during updates and return HTTP 409 for duplicate domain claims. Add regression tests for domain validation, ownership, and service creation and updates. --- .../domain/manager/domain_test.go | 50 ++- .../reverseproxy/domain/manager/manager.go | 64 +++- .../domain/manager/manager_realstore_test.go | 326 ++++++++++++++++++ .../domain/manager/manager_test.go | 4 + .../service/manager/domain_validation_test.go | 127 +++++++ .../reverseproxy/service/manager/manager.go | 19 +- management/server/store/sql_store.go | 29 ++ management/server/store/store.go | 1 + management/server/store/store_mock.go | 15 + 9 files changed, 612 insertions(+), 23 deletions(-) create mode 100644 management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go create mode 100644 management/internals/modules/reverseproxy/service/manager/domain_validation_test.go diff --git a/management/internals/modules/reverseproxy/domain/manager/domain_test.go b/management/internals/modules/reverseproxy/domain/manager/domain_test.go index 523920a99..38d5a923b 100644 --- a/management/internals/modules/reverseproxy/domain/manager/domain_test.go +++ b/management/internals/modules/reverseproxy/domain/manager/domain_test.go @@ -66,8 +66,8 @@ func TestExtractClusterFromFreeDomain(t *testing.T) { func TestExtractClusterFromCustomDomains(t *testing.T) { customDomains := []*domain.Domain{ - {Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io"}, - {Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io"}, + {Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: true}, + {Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io", Validated: true}, } tests := []struct { @@ -120,19 +120,49 @@ func TestExtractClusterFromCustomDomains(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) - assert.Equal(t, tc.wantOK, ok) - if ok { - assert.Equal(t, tc.wantVal, cluster) + cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains) + if !tc.wantOK { + assert.Equal(t, customDomainNoMatch, match, "unrelated domain should not match any custom domain") + return } + assert.Equal(t, customDomainValidated, match, "validated custom domain should resolve a cluster") + assert.Equal(t, tc.wantVal, cluster) }) } } +// An unvalidated row must never yield a cluster: the account has not shown it +// controls the name, so no service may be bound to it. +func TestExtractClusterFromCustomDomains_UnvalidatedDomainRefused(t *testing.T) { + customDomains := []*domain.Domain{ + {Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: false}, + } + + for _, serviceDomain := range []string{"example.com", "app.example.com"} { + t.Run(serviceDomain, func(t *testing.T) { + cluster, match := extractClusterFromCustomDomains(serviceDomain, customDomains) + assert.Equal(t, customDomainUnvalidated, match, "unvalidated row must be reported as such") + assert.Empty(t, cluster, "unvalidated row must not resolve a cluster") + }) + } +} + +// A more specific unvalidated row must not shadow a validated parent domain. +func TestExtractClusterFromCustomDomains_ValidatedParentWinsOverUnvalidatedChild(t *testing.T) { + customDomains := []*domain.Domain{ + {Domain: "example.com", TargetCluster: "cluster-generic", Validated: true}, + {Domain: "app.example.com", TargetCluster: "cluster-app", Validated: false}, + } + + cluster, match := extractClusterFromCustomDomains("app.example.com", customDomains) + assert.Equal(t, customDomainValidated, match) + assert.Equal(t, "cluster-generic", cluster, "validated parent domain should provide the cluster") +} + func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) { customDomains := []*domain.Domain{ - {Domain: "example.com", TargetCluster: "cluster-generic"}, - {Domain: "app.example.com", TargetCluster: "cluster-app"}, + {Domain: "example.com", TargetCluster: "cluster-generic", Validated: true}, + {Domain: "app.example.com", TargetCluster: "cluster-app", Validated: true}, } tests := []struct { @@ -164,8 +194,8 @@ func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) - assert.True(t, ok) + cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains) + assert.Equal(t, customDomainValidated, match) assert.Equal(t, tc.wantVal, cluster) }) } diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go index a9774d0e9..46e4ced83 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -26,6 +26,7 @@ type store interface { GetAgentNetworkSettings(ctx context.Context, lockStrength nbstore.LockingStrength, accountID string) (*agentnetworkTypes.Settings, error) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) + GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) @@ -150,6 +151,10 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName return nil, fmt.Errorf("target cluster %s is not available", targetCluster) } + if err := m.checkDomainAvailable(ctx, domainName); err != nil { + return nil, err + } + // Attempt an initial validation against the specified cluster only var validated bool if m.validator.IsValid(ctx, domainName, []string{targetCluster}) { @@ -166,6 +171,23 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName return d, nil } +// checkDomainAvailable reports whether the domain is free to claim. The unique +// index on the column is the real guard; this turns the violation into a +// conflict the caller can act on instead of a database error, and says nothing +// about which account holds the domain. +func (m Manager) checkDomainAvailable(ctx context.Context, domainName string) error { + _, err := m.store.GetCustomDomainByName(ctx, domainName) + if err == nil { + return status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName) + } + + if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { + return nil + } + + return fmt.Errorf("look up domain: %w", err) +} + func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error { ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) if err != nil { @@ -203,7 +225,9 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID log.WithFields(log.Fields{ "accountID": accountID, "domainID": domainID, - }).WithError(err).Error("validate domain") + "userID": userID, + }).Error("validate domain: permission denied") + return } log.WithFields(log.Fields{ @@ -298,9 +322,12 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain return "", fmt.Errorf("list custom domains: %w", err) } - targetCluster, valid := extractClusterFromCustomDomains(domain, customDomains) - if valid { + targetCluster, match := extractClusterFromCustomDomains(domain, customDomains) + switch match { + case customDomainValidated: return targetCluster, nil + case customDomainUnvalidated: + return "", status.Errorf(status.PreconditionFailed, "domain %s is not validated", domain) } return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain) @@ -363,19 +390,46 @@ func (m Manager) reservedGatewayAddress(ctx context.Context, accountID string) ( return settings.ProxyAddress, nil } -func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) { +// customDomainMatch describes how a service domain relates to the account's +// custom domain rows. +type customDomainMatch int + +const ( + customDomainNoMatch customDomainMatch = iota + customDomainUnvalidated + customDomainValidated +) + +// extractClusterFromCustomDomains finds the longest custom domain covering the +// service domain and reports its target cluster. Only a validated row yields a +// cluster: until the CNAME check has passed the account has not shown it +// controls the name, so no traffic may be routed for it. +func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, customDomainMatch) { bestCluster := "" bestLen := -1 + matched := false for _, cd := range customDomains { if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) { continue } + matched = true + if !cd.Validated { + continue + } if l := len(cd.Domain); l > bestLen { bestLen = l bestCluster = cd.TargetCluster } } - return bestCluster, bestLen >= 0 + + switch { + case bestLen >= 0: + return bestCluster, customDomainValidated + case matched: + return "", customDomainUnvalidated + default: + return "", customDomainNoMatch + } } // ExtractClusterFromFreeDomain extracts the cluster address from a free domain. diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go new file mode 100644 index 000000000..8a0b56171 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go @@ -0,0 +1,326 @@ +package manager + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/permissions" + nbstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + testCluster = "eu.proxy.test" + accountA = "account-a" + accountAUser = "account-a-admin" + accountB = "account-b" + accountBUser = "account-b-admin" + accountAMember = "account-a-member" +) + +// stubResolver answers CNAME lookups from a table the test controls, so a +// domain can point at the cluster or nowhere without touching a real resolver. +type stubResolver struct { + mu sync.Mutex + cnames map[string]string +} + +func (r *stubResolver) LookupCNAME(_ context.Context, host string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + cname, ok := r.cnames[host] + if !ok { + return "", fmt.Errorf("lookup %s: no such host", host) + } + return cname + ".", nil +} + +func (r *stubResolver) set(host, cname string) { + r.mu.Lock() + defer r.mu.Unlock() + r.cnames[host] = cname +} + +type domainTestEnv struct { + manager Manager + store nbstore.Store + resolver *stubResolver +} + +// setupDomainTest builds the domain manager on a real SQLite store with two +// accounts and one active public proxy cluster. +func setupDomainTest(t *testing.T) *domainTestEnv { + t.Helper() + + ctx := context.Background() + testStore, cleanup, err := nbstore.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + for accountID, userID := range map[string]string{accountA: accountAUser, accountB: accountBUser} { + users := map[string]*types.User{ + userID: { + Id: userID, + AccountID: accountID, + Role: types.UserRoleAdmin, + }, + } + if accountID == accountA { + // A real member of the account whose role denies Services:Create, so + // permission denial is exercised as ok=false rather than as a lookup + // error for a user who is not in the account at all. + users[accountAMember] = &types.User{ + Id: accountAMember, + AccountID: accountID, + Role: types.UserRoleUser, + } + } + + require.NoError(t, testStore.SaveAccount(ctx, &types.Account{ + Id: accountID, + CreatedBy: userID, + Settings: &types.Settings{}, + Users: users, + })) + } + + proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + + _, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", testCluster, "127.0.0.1", nil, nil) + require.NoError(t, err) + + resolver := &stubResolver{cnames: make(map[string]string)} + + mgr := Manager{ + store: testStore, + proxyManager: proxyMgr, + validator: domain.Validator{Resolver: resolver}, + permissionsManager: permissions.NewManager(testStore), + accountManager: &mock_server.MockAccountManager{ + StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {}, + }, + } + + return &domainTestEnv{manager: mgr, store: testStore, resolver: resolver} +} + +// storedDomain reads a domain row back through the store so assertions are made +// on what was persisted rather than on the value the manager returned. +func storedDomain(t *testing.T, s nbstore.Store, accountID, domainName string) *domain.Domain { + t.Helper() + + domains, err := s.ListCustomDomains(context.Background(), accountID) + require.NoError(t, err) + for _, d := range domains { + if d.Domain == domainName { + return d + } + } + return nil +} + +// A domain whose CNAME check fails is stored unvalidated and must not resolve a +// cluster, which is what service creation gates on. +func TestCreateDomain_FailedLookupIsNotServable(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "apps.example.com", testCluster) + require.NoError(t, err) + assert.False(t, created.Validated, "a domain whose CNAME lookup fails must not be created validated") + + stored := storedDomain(t, env.store, accountA, "apps.example.com") + require.NotNil(t, stored, "domain row should exist") + assert.False(t, stored.Validated, "persisted row must be unvalidated") + + cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "apps.example.com") + require.Error(t, err, "an unvalidated domain must not resolve a cluster") + assert.Empty(t, cluster) + assert.Contains(t, err.Error(), "not validated", "error should tell the caller what to fix") + + sErr, ok := status.FromError(err) + require.True(t, ok, "error should be a typed status error") + assert.Equal(t, status.PreconditionFailed, sErr.Type()) + + _, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "sub.apps.example.com") + assert.Error(t, err, "subdomains of an unvalidated custom domain are not servable either") +} + +// A second account claiming a registered domain gets a clean conflict, not a +// database error surfaced as a 500. +func TestCreateDomain_DuplicateIsAConflict(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + _, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "shared.example.com", testCluster) + require.NoError(t, err) + + _, err = env.manager.CreateDomain(ctx, accountB, accountBUser, "shared.example.com", testCluster) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok, "conflict must be a typed status error, not a raw database error") + assert.Equal(t, status.AlreadyExists, sErr.Type(), "conflict should map to 409, not 500") + assert.NotContains(t, sErr.Message, accountA, "the response must not reveal the holding account") + + assert.Nil(t, storedDomain(t, env.store, accountB, "shared.example.com"), "no row should be written on conflict") +} + +// The same account re-adding one of its own domains is a conflict too. +func TestCreateDomain_SameAccountDuplicateIsAConflict(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + _, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster) + require.NoError(t, err) + + _, err = env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.AlreadyExists, sErr.Type()) +} + +// The negative control: a validated domain still derives its cluster, for the +// bare name and for subdomains, exactly as before. +func TestCreateDomain_ValidatedDomainDerivesCluster(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + env.resolver.set("validation.valid.example.com", testCluster) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "valid.example.com", testCluster) + require.NoError(t, err) + require.True(t, created.Validated, "a matching CNAME should validate on create") + + cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "valid.example.com") + require.NoError(t, err) + assert.Equal(t, testCluster, cluster) + + cluster, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "app.valid.example.com") + require.NoError(t, err) + assert.Equal(t, testCluster, cluster, "subdomains of a validated custom domain resolve too") +} + +// Validating a domain flips the gate: the same lookup that failed before now +// resolves a cluster. +func TestValidateDomain_UnlocksClusterDerivation(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "later.example.com", testCluster) + require.NoError(t, err) + require.False(t, created.Validated) + + _, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "later.example.com") + require.Error(t, err) + + env.resolver.set("validation.later.example.com", testCluster) + env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID) + + require.True(t, storedDomain(t, env.store, accountA, "later.example.com").Validated) + + cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "later.example.com") + require.NoError(t, err) + assert.Equal(t, testCluster, cluster) +} + +// Free cluster domains are unaffected by the custom domain gate. +func TestDeriveClusterFromDomain_FreeDomainUnaffected(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "myapp.abc123."+testCluster) + require.NoError(t, err) + assert.Equal(t, testCluster, cluster) +} + +// The manager pre-check exists to turn a conflict into a 409, but the unique +// index on the column is what actually guarantees the domain is claimed once. +// +// Two requests can clear the pre-check concurrently and race to the insert. +// Inserting twice through the store reaches the same code path the loser of +// that race takes, without the nondeterminism of driving it from goroutines, +// and the loser must still see a conflict rather than an internal error. +func TestStore_DuplicateDomainRejectedByIndexAsConflict(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + _, err := env.store.CreateCustomDomain(ctx, accountA, "indexed.example.com", testCluster, false) + require.NoError(t, err) + + _, err = env.store.CreateCustomDomain(ctx, accountB, "indexed.example.com", testCluster, false) + require.Error(t, err, "the unique index must reject the same domain in a second account") + + sErr, ok := status.FromError(err) + require.True(t, ok, "the losing insert must return a typed status error") + assert.Equal(t, status.AlreadyExists, sErr.Type(), "a lost race is a 409, not a 500") +} + +// Validation is what decides whether a domain routes traffic, so a caller +// without permission to it must not be able to flip the flag. The check logged +// the denial and then carried on, which was inert while nothing read Validated +// and is not once cluster derivation gates on it. +func TestValidateDomain_PermissionDeniedDoesNotValidate(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "guarded.example.com", testCluster) + require.NoError(t, err) + require.False(t, created.Validated) + + // The CNAME is in place, so the only thing standing between this caller and + // a validated domain is the permission check. + env.resolver.set("validation.guarded.example.com", testCluster) + + env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID) + + stored := storedDomain(t, env.store, accountA, "guarded.example.com") + require.NotNil(t, stored) + assert.False(t, stored.Validated, "a caller without permission must not validate the domain") + + _, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "guarded.example.com") + assert.Error(t, err, "the domain must still be unservable") +} + +// Validation runs asynchronously, so it can finish after the domain was +// deleted and then write a stale row back. gorm's Save falls back to an insert +// when an update affects no rows, which would resurrect the domain as +// validated; UpdateCustomDomain avoids that by selecting explicit columns. +// This pins that behaviour, since dropping the Select would reintroduce it. +func TestUpdateCustomDomain_DoesNotResurrectDeletedDomain(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "racy.example.com", testCluster) + require.NoError(t, err) + + stale := storedDomain(t, env.store, accountA, "racy.example.com") + require.NotNil(t, stale) + + require.NoError(t, env.manager.DeleteDomain(ctx, accountA, accountAUser, created.ID)) + require.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"), "the domain should be gone") + + // What an in-flight validation would write once its CNAME check succeeded. + // The write has to succeed for the assertion below to mean anything: a + // rejected write would leave the domain absent for the wrong reason. + stale.Validated = true + _, err = env.store.UpdateCustomDomain(ctx, accountA, stale) + require.NoError(t, err, "the update itself must succeed, so absence is not just a failed write") + + assert.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"), + "a late validation write must not recreate a deleted domain") +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_test.go index 12281b447..519f5efeb 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager_test.go @@ -184,6 +184,10 @@ func (s *stubStore) GetCustomDomain(context.Context, string, string) (*domain.Do panic("not used in allow-list tests") } +func (s *stubStore) GetCustomDomainByName(context.Context, string) (*domain.Domain, error) { + panic("not used in allow-list tests") +} + func (s *stubStore) ListFreeDomains(context.Context, string) ([]string, error) { panic("not used in allow-list tests") } diff --git a/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go new file mode 100644 index 000000000..ccb955cd8 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go @@ -0,0 +1,127 @@ +package manager + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + + domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +const validationTestCluster = "eu.proxy.test" + +// withRealDomainManager swaps the stub cluster deriver for the real domain +// manager backed by the same store, so service creation is gated by the actual +// domain rows rather than by a test double that always agrees. +func withRealDomainManager(t *testing.T, mgr *Manager, testStore store.Store) { + t.Helper() + + ctx := context.Background() + proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + + _, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", validationTestCluster, "127.0.0.1", nil, nil) + require.NoError(t, err) + + accountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {}, + } + mgr.clusterDeriver = domainmanager.NewManager(testStore, proxyMgr, permissions.NewManager(testStore), accountMgr) +} + +func newTestService(domain string) *rpservice.Service { + return &rpservice.Service{ + Name: "test-service", + Domain: domain, + Enabled: true, + Mode: rpservice.ModeHTTP, + Targets: []*rpservice.Target{{ + Host: "10.0.0.1", + Port: 8080, + Protocol: "http", + TargetId: testPeerID, + TargetType: "peer", + Enabled: true, + }}, + } +} + +// A service must not bind to a domain the account has not validated, and +// nothing may be persisted for the attempt. +func TestCreateService_RefusesUnvalidatedDomain(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + + _, err := testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false) + require.NoError(t, err) + + _, err = mgr.CreateService(ctx, testAccountID, testUserID, newTestService("unproven.example.com")) + require.Error(t, err, "an unvalidated domain must not bind a service") + assert.Contains(t, err.Error(), "not validated", "the API error should name the actual problem") + + sErr, ok := status.FromError(err) + require.True(t, ok, "error should be a typed status error") + assert.Equal(t, status.PreconditionFailed, sErr.Type()) + + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "no service row should be written for a refused domain") +} + +// The negative control: a validated domain still binds a service and derives +// its cluster exactly as before. +func TestCreateService_ValidatedDomainBindsService(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + + _, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true) + require.NoError(t, err) + + created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com")) + require.NoError(t, err) + assert.Equal(t, validationTestCluster, created.ProxyCluster, "service should bind to the domain's target cluster") + + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "the service should be persisted") + assert.Equal(t, "app.proven.example.com", services[0].Domain) +} + +// An update must not be a way around the creation gate: moving a live service +// onto an unvalidated domain has to fail rather than silently keep the old +// cluster and start serving the new hostname. +func TestUpdateService_RefusesMoveToUnvalidatedDomain(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + + _, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true) + require.NoError(t, err) + _, err = testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false) + require.NoError(t, err) + + created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com")) + require.NoError(t, err) + + moved := *created + moved.Domain = "app.unproven.example.com" + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, &moved) + require.Error(t, err, "moving to an unvalidated domain must fail") + assert.Contains(t, err.Error(), "not validated") + + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID) + require.NoError(t, err) + assert.Equal(t, "app.proven.example.com", stored.Domain, "the service must keep its original domain") +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 365fbab40..9c7f95eb4 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -606,16 +606,19 @@ func (m *Manager) resolveEffectiveCluster(ctx context.Context, accountID string, return existing.ProxyCluster, nil } - if m.clusterDeriver != nil { - derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain) - if err != nil { - log.WithError(err).Warnf("could not derive cluster from domain %s", svc.Domain) - } else { - return derived, nil - } + if m.clusterDeriver == nil { + return existing.ProxyCluster, nil } - return existing.ProxyCluster, nil + // Falling back to the old cluster here would let an update move a service + // onto a domain the account has not validated, bypassing the check that + // creation makes. + derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain) + if err != nil { + return "", status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", svc.Domain, err) + } + + return derived, nil } func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error { diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 6337ebf1a..ef353ea83 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5686,6 +5686,23 @@ func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]* return domains, nil } +// GetCustomDomainByName returns the custom domain row holding the given name, +// regardless of which account owns it. +func (s *SqlStore) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) { + customDomain := &domain.Domain{} + result := s.db.Take(customDomain, "domain = ?", domainName) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainName) + } + + log.WithContext(ctx).Errorf("failed to get custom domain by name from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get custom domain from store") + } + + return customDomain, nil +} + func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) { newDomain := &domain.Domain{ ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us. @@ -5697,6 +5714,18 @@ func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, dom } result := s.db.Create(newDomain) if result.Error != nil { + // The unique index is the last guard when two requests clear the + // manager's availability check at the same time. The one that loses the + // insert is a conflict, not an internal failure. + var count int64 + if err := s.db.Model(&domain.Domain{}).Where("domain = ?", domainName).Count(&count).Error; err == nil && count > 0 { + // The insert error is logged even on this path: the name being taken + // is what the caller has to act on, but if the insert also failed for + // an unrelated reason the operator still needs to see it. + log.WithContext(ctx).Warnf("create reverse proxy custom domain %s rejected, name already registered: %v", domainName, result.Error) + return nil, status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName) + } + log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error) return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store") } diff --git a/management/server/store/store.go b/management/server/store/store.go index 7daeb28a9..da2b3c6e0 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -302,6 +302,7 @@ type Store interface { GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) + GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 70acb9f58..9bf49f076 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1941,6 +1941,21 @@ func (mr *MockStoreMockRecorder) GetCustomDomain(ctx, accountID, domainID any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomain", reflect.TypeOf((*MockStore)(nil).GetCustomDomain), ctx, accountID, domainID) } +// GetCustomDomainByName mocks base method. +func (m *MockStore) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCustomDomainByName", ctx, domainName) + ret0, _ := ret[0].(*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCustomDomainByName indicates an expected call of GetCustomDomainByName. +func (mr *MockStoreMockRecorder) GetCustomDomainByName(ctx, domainName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomainByName", reflect.TypeOf((*MockStore)(nil).GetCustomDomainByName), ctx, domainName) +} + // GetCustomDomainsCounts mocks base method. func (m *MockStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) { m.ctrl.T.Helper() From 15a684248c7e33556fe6535662ec8ded719b7db9 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Thu, 10 Sep 2026 12:02:19 +0200 Subject: [PATCH 24/63] [client] Support arbitrary UIDs in rootless image (#7440) * [client] Support arbitrary UIDs in rootless image * [client] Keep rootless executables root-owned * [client] Harden arbitrary UID image validation * [client] Preserve executable access in rootless image Keep the binary and entrypoint executable when deployments override the runtime group. Retain root ownership so non-root users cannot modify either file. * [client] Verify rootless state reuse with a stable UID Persisted profiles remain scoped to the creating UID. Verify same-UID container recreation without broadening application permissions, and document the Kubernetes volume permission behavior observed on OpenShift. Remove unused synthetic-user home metadata. * [client] Separate image changes from invoking user fix Keep this PR limited to resolving unmapped non-root invoking users. Move container permissions and their smoke test to a dependent image branch so they can be reviewed separately. * [client] Restore invoking process user test Retain coverage for successful current-user lookup without sudo. Numeric-identity fallback tests do not cover this existing behavior. --- .../internal/profilemanager/invoking_user.go | 35 ++++++++-- .../profilemanager/invoking_user_test.go | 70 ++++++++++++++++++- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/client/internal/profilemanager/invoking_user.go b/client/internal/profilemanager/invoking_user.go index c86a6ce43..7ba612ffb 100644 --- a/client/internal/profilemanager/invoking_user.go +++ b/client/internal/profilemanager/invoking_user.go @@ -6,6 +6,7 @@ import ( "os/user" "path/filepath" "runtime" + "strconv" log "github.com/sirupsen/logrus" ) @@ -13,17 +14,21 @@ import ( const envSudoUser = "SUDO_USER" var ( - geteuid = os.Geteuid - lookupUser = user.Lookup + currentUser = user.Current + getegid = os.Getegid + 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. +// root's (default) profile instead of the invoking user's. An unmapped positive +// process UID uses its numeric kernel identity; root, sudo lookup failures, and +// unavailable platform identities still fail closed. Privilege decisions 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 @@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) { if sudoActive() { return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser)) } - return user.Current() + u, err := currentUser() + if err == nil { + return u, nil + } + + uid := geteuid() + if uid <= 0 { + return nil, err + } + + log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err) + uidString := strconv.Itoa(uid) + return &user.User{ + Username: uidString, + Uid: uidString, + Gid: strconv.Itoa(getegid()), + }, nil } // IsPlainRoot reports that the process runs as root with no usable sudo diff --git a/client/internal/profilemanager/invoking_user_test.go b/client/internal/profilemanager/invoking_user_test.go index 54c8ad8fd..159d2616b 100644 --- a/client/internal/profilemanager/invoking_user_test.go +++ b/client/internal/profilemanager/invoking_user_test.go @@ -2,6 +2,7 @@ package profilemanager import ( "errors" + "fmt" "io/fs" "os" "os/user" @@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - assert.Equal(t, current.Username, got.Username) + assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo") +} + +func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) { + for _, uid := range []int{0, -1} { + t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) { + t.Setenv(envSudoUser, "") + lookupErr := errors.New("current user unavailable") + fakeUnmappedUser(t, uid, 0, lookupErr) + + got, err := InvokingUser() + require.ErrorIs(t, err, lookupErr) + assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity") + }) + } +} + +func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) { + t.Setenv(envSudoUser, "") + fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000")) + + profilesRoot := t.TempDir() + origDir := DefaultConfigPathDir + origOverride := ConfigDirOverride + DefaultConfigPathDir = profilesRoot + ConfigDirOverride = "" + t.Cleanup(func() { + DefaultConfigPathDir = origDir + ConfigDirOverride = origOverride + }) + + profileID := ID("0123456789abcdef0123456789abcdef") + got, err := (&Profile{ID: profileID}).FilePath() + require.NoError(t, err) + assert.Equal(t, + filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"), + got, + "profile path should use the numeric UID namespace", + ) + + entries, err := os.ReadDir(profilesRoot) + require.NoError(t, err) + require.Len(t, entries, 1, "only the numeric UID directory should be created") + assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric") + assert.True(t, entries[0].IsDir(), "profile namespace should be a directory") } func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) { @@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) { fakeSudo(t, filepath.Join("/home", "misha")) lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + origCurrentUser := currentUser + currentUser = func() (*user.User, error) { + t.Fatal("currentUser must not be called after a sudo lookup failure") + return nil, errors.New("currentUser called unexpectedly") + } + t.Cleanup(func() { currentUser = origCurrentUser }) + got, err := InvokingUser() require.Error(t, err) assert.Nil(t, got, "must not resolve to the root process user") @@ -215,6 +267,22 @@ func fakeSudo(t *testing.T, home string) { }) } +func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) { + t.Helper() + + origCurrentUser := currentUser + origEuid := geteuid + origEgid := getegid + currentUser = func() (*user.User, error) { return nil, lookupErr } + geteuid = func() int { return uid } + getegid = func() int { return gid } + t.Cleanup(func() { + currentUser = origCurrentUser + geteuid = origEuid + getegid = origEgid + }) +} + func assertNoEntries(t *testing.T, root string) { t.Helper() err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { From 9615d2ab162e7badee5c8b4e84048ce49e1db6e7 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 10 Sep 2026 12:06:30 +0200 Subject: [PATCH 25/63] [client] Report the remote jobs key in the MDM UI snapshot (#7485) * [client] Report the remote jobs key in the MDM UI snapshot Co-Authored-By: Claude Opus 5 (1M context) * [client] Align the remote jobs snapshot key with the policy key The snapshot field carried the JSON tag remoteJobsAllowed while the policy key is allowRemoteJobs. GetConfigResponse.mDMManagedFields reports the raw policy keys, and applyMDMRestrictions matches them against the struct's JSON tags, so the field never turned true for a policy that set the key. Every other field in Fields already uses its policy key as the JSON tag; this was the only divergence. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- client/mdm/restrictions.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/mdm/restrictions.go b/client/mdm/restrictions.go index c8e443395..200756b78 100644 --- a/client/mdm/restrictions.go +++ b/client/mdm/restrictions.go @@ -20,6 +20,7 @@ type Fields struct { DisableMetricsCollection bool `json:"disableMetricsCollection"` SplitTunnelMode bool `json:"splitTunnelMode"` SplitTunnelApps bool `json:"splitTunnelApps"` + RemoteJobsAllowed bool `json:"allowRemoteJobs"` DisableAdvancedView *bool `json:"disableAdvancedView"` } @@ -60,6 +61,7 @@ func BuildRestrictions(policy *Policy) Restrictions { r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection) r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode) r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps) + r.MDM.RemoteJobsAllowed = policy.HasKey(KeyRemoteJobsAllowed) if v, ok := policy.GetBool(KeyAllowServerSSH); ok { r.MDM.AllowServerSSH = &v } From 0fac1ee638d89a5b2a5cfef38b9c638d57bfad24 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Thu, 10 Sep 2026 16:37:43 +0200 Subject: [PATCH 26/63] [management] cleanup resources when ws-grpc proxy connection goes away (#7484) * ws to grpc connection adapter Signed-off-by: Dmitri Dolguikh * support for timeouts on reading h2 stream headers Signed-off-by: Dmitri Dolguikh * cleanups Signed-off-by: Dmitri Dolguikh * we can't always expect a DATA frame, as not all http methods send it Signed-off-by: Dmitri Dolguikh * set default headers read timeout to 10s Signed-off-by: Dmitri Dolguikh * fix a race in tests Signed-off-by: Dmitri Dolguikh * remove frame interceptor Signed-off-by: Dmitri Dolguikh * cleanup test cleanup Signed-off-by: Dmitri Dolguikh * make linter happy Signed-off-by: Dmitri Dolguikh * removed unused consts Signed-off-by: Dmitri Dolguikh * set 5s ReadTimeout Signed-off-by: Dmitri Dolguikh * making linter happy Signed-off-by: Dmitri Dolguikh * making linter happy Signed-off-by: Dmitri Dolguikh * updated comments Signed-off-by: Dmitri Dolguikh * fix spelling Signed-off-by: Dmitri Dolguikh * disabled all http server read timeouts Signed-off-by: Dmitri Dolguikh * Revert "disabled all http server read timeouts" This reverts commit adf5005ba44d42f620d3a0a0851df9854cfd180d. Signed-off-by: Dmitri Dolguikh * clarify comment re: ReadTimeout/WriteTimeout issues Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- util/wsproxy/server/proxy.go | 163 +++++----------- util/wsproxy/server/ws_conn_adapter.go | 126 ++++++++++++ util/wsproxy/server/ws_conn_adapter_test.go | 204 ++++++++++++++++++++ 3 files changed, 373 insertions(+), 120 deletions(-) create mode 100644 util/wsproxy/server/ws_conn_adapter.go create mode 100644 util/wsproxy/server/ws_conn_adapter_test.go diff --git a/util/wsproxy/server/proxy.go b/util/wsproxy/server/proxy.go index ffb622200..0618beb91 100644 --- a/util/wsproxy/server/proxy.go +++ b/util/wsproxy/server/proxy.go @@ -1,12 +1,8 @@ package server import ( - "context" - "io" - "net" "net/http" - "sync" - "time" + "sync/atomic" "github.com/coder/websocket" log "github.com/sirupsen/logrus" @@ -15,11 +11,6 @@ import ( "github.com/netbirdio/netbird/util/wsproxy" ) -const ( - bufferSize = 32 * 1024 - ioTimeout = 5 * time.Second -) - // Config contains the configuration for the WebSocket proxy. type Config struct { Handler http.Handler @@ -53,14 +44,23 @@ func New(handler http.Handler, opts ...Option) *Proxy { // Handler returns an http.Handler that proxies WebSocket connections to the local gRPC server. func (p *Proxy) Handler() http.Handler { - return http.HandlerFunc(p.handleWebSocket) + return &proxyHandler{ + metrics: p.config.MetricsRecorder, + handler: p.config.Handler, + } } -func (p *Proxy) handleWebSocket(w http.ResponseWriter, r *http.Request) { +type proxyHandler struct { + metrics MetricsRecorder + handler http.Handler + conn atomic.Pointer[wsConnAdapter] +} + +func (ph *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - p.metrics.RecordConnection(ctx) - defer p.metrics.RecordDisconnection(ctx) + ph.metrics.RecordConnection(ctx) + defer ph.metrics.RecordDisconnection(ctx) log.Debugf("WebSocket proxy handling connection from %s, forwarding to internal gRPC handler", r.RemoteAddr) acceptOptions := &websocket.AcceptOptions{ @@ -69,121 +69,44 @@ func (p *Proxy) handleWebSocket(w http.ResponseWriter, r *http.Request) { wsConn, err := websocket.Accept(w, r, acceptOptions) if err != nil { - p.metrics.RecordError(ctx, "websocket_accept_failed") + ph.metrics.RecordError(ctx, "websocket_accept_failed") log.Errorf("WebSocket upgrade failed from %s: %v", r.RemoteAddr, err) return } - defer func() { - _ = wsConn.Close(websocket.StatusNormalClosure, "") - }() + serverConn := (&wsConnAdapter{ + ctx: ctx, + conn: wsConn, + metrics: ph.metrics, + clientAddr: r.RemoteAddr, + }) - clientConn, serverConn := net.Pipe() defer func() { - _ = clientConn.Close() _ = serverConn.Close() }() + ph.conn.Store(serverConn) // used in tests only + log.Debugf("WebSocket proxy established: %s -> gRPC handler", r.RemoteAddr) - go func() { - (&http2.Server{}).ServeConn(serverConn, &http2.ServeConnOpts{ - Context: ctx, - Handler: p.config.Handler, - }) - }() + (&http2.Server{ + // TODO (dmitri) we should limit the number of concurrent streams per connection (peer) + // and idle timeouts + // MaxConcurrentStreams: 20, + // IdleTimeout: 10 * time.Second, + }).ServeConn(serverConn, &http2.ServeConnOpts{ + Context: ctx, + Handler: ph.handler, + BaseConfig: &http.Server{ + // b/c we are wrapping a ws connection, read and write connection deadlines normally set + // via ReadTimeout and WriteTimeout http.Server fields aren't available to us. The ws + // library doesn't expose connection deadline timer config, and we ignore these calls in "wsConnAdapter". + // + // Another issue is that Server.ServeConn() call bypasses setting of connection deadlines altogether, + // ReadTimeout and Writetimeout set here would only apply to h2 streams, i.e. after a HEADERS frame + // arrival and processing, turning ReadTimeout into a request body read deadline, and WriteTimeout into + // a response deadline (the latter not useful for streaming requests). + }, + }) - p.proxyData(ctx, wsConn, clientConn, r.RemoteAddr) -} - -func (p *Proxy) proxyData(ctx context.Context, wsConn *websocket.Conn, pipeConn net.Conn, clientAddr string) { - proxyCtx, cancel := context.WithCancel(ctx) - defer cancel() - - var wg sync.WaitGroup - wg.Add(2) - - go p.wsToPipe(proxyCtx, cancel, &wg, wsConn, pipeConn, clientAddr) - go p.pipeToWS(proxyCtx, cancel, &wg, wsConn, pipeConn, clientAddr) - - wg.Wait() -} - -func (p *Proxy) wsToPipe(ctx context.Context, cancel context.CancelFunc, wg *sync.WaitGroup, wsConn *websocket.Conn, pipeConn net.Conn, clientAddr string) { - defer wg.Done() - defer cancel() - - for { - msgType, data, err := wsConn.Read(ctx) - if err != nil { - switch { - case ctx.Err() != nil: - log.Debugf("WebSocket from %s terminating due to context cancellation", clientAddr) - case websocket.CloseStatus(err) != -1: - log.Debugf("WebSocket from %s disconnected", clientAddr) - default: - p.metrics.RecordError(ctx, "websocket_read_error") - log.Debugf("WebSocket read error from %s: %v", clientAddr, err) - } - return - } - - if msgType != websocket.MessageBinary { - log.Warnf("Unexpected WebSocket message type from %s: %v", clientAddr, msgType) - continue - } - - if ctx.Err() != nil { - log.Tracef("wsToPipe goroutine terminating due to context cancellation before pipe write") - return - } - - if err := pipeConn.SetWriteDeadline(time.Now().Add(ioTimeout)); err != nil { - log.Debugf("Failed to set pipe write deadline: %v", err) - } - - n, err := pipeConn.Write(data) - if err != nil { - p.metrics.RecordError(ctx, "pipe_write_error") - log.Warnf("Pipe write error for %s: %v", clientAddr, err) - return - } - - p.metrics.RecordBytesTransferred(ctx, "ws_to_grpc", int64(n)) - } -} - -func (p *Proxy) pipeToWS(ctx context.Context, cancel context.CancelFunc, wg *sync.WaitGroup, wsConn *websocket.Conn, pipeConn net.Conn, clientAddr string) { - defer wg.Done() - defer cancel() - - buf := make([]byte, bufferSize) - for { - n, err := pipeConn.Read(buf) - if err != nil { - if ctx.Err() != nil { - log.Tracef("pipeToWS goroutine terminating due to context cancellation") - return - } - - if err != io.EOF { - log.Debugf("Pipe read error for %s: %v", clientAddr, err) - } - return - } - - if ctx.Err() != nil { - log.Tracef("pipeToWS goroutine terminating due to context cancellation before WebSocket write") - return - } - - if n > 0 { - if err := wsConn.Write(ctx, websocket.MessageBinary, buf[:n]); err != nil { - p.metrics.RecordError(ctx, "websocket_write_error") - log.Warnf("WebSocket write error for %s: %v", clientAddr, err) - return - } - - p.metrics.RecordBytesTransferred(ctx, "grpc_to_ws", int64(n)) - } - } + log.Debugf("WebSocket proxy closing: %s -> gRPC handler", r.RemoteAddr) } diff --git a/util/wsproxy/server/ws_conn_adapter.go b/util/wsproxy/server/ws_conn_adapter.go new file mode 100644 index 000000000..eb29ab0cb --- /dev/null +++ b/util/wsproxy/server/ws_conn_adapter.go @@ -0,0 +1,126 @@ +package server + +import ( + "context" + "net" + "sync/atomic" + "time" + + "github.com/coder/websocket" + log "github.com/sirupsen/logrus" +) + +type wsConnAdapter struct { + prefix string + ctx context.Context + conn *websocket.Conn + metrics MetricsRecorder + clientAddr string + closed atomic.Bool + bufferedRead []byte +} + +var _ net.Conn = &wsConnAdapter{} + +type wsAddr struct{ prefix string } + +func (wa wsAddr) Network() string { return wa.prefix + "ws-proxy" } +func (wa wsAddr) String() string { return wa.prefix + "ws-proxy" } + +func (ws *wsConnAdapter) Read(b []byte) (int, error) { + if len(ws.bufferedRead) > 0 { + return ws.readFromBuffer(b) + } + + msgType, data, err := ws.conn.Read(ws.ctx) + if err != nil { + switch { + case ws.ctx.Err() != nil: + log.Debugf("WebSocket from %s terminating due to context cancellation", ws.clientAddr) + case websocket.CloseStatus(err) != -1: + log.Debugf("WebSocket from %s disconnected", ws.clientAddr) + default: + ws.recordError(ws.ctx, "websocket_read_error") + log.Debugf("WebSocket read error from %s: %v", ws.clientAddr, err) + } + return copy(b, data), err + } + if msgType != websocket.MessageBinary { + log.Warnf("Unexpected WebSocket message type from %s: %v", ws.clientAddr, msgType) + return 0, nil + } + + ws.bufferedRead = data + return ws.readFromBuffer(b) +} + +func (ws *wsConnAdapter) readFromBuffer(b []byte) (int, error) { + n := copy(b, ws.bufferedRead) + + ws.recordBytesTransferred(ws.ctx, "ws_to_grpc", n) + if n == len(ws.bufferedRead) { + ws.bufferedRead = nil + return n, nil + } else { + ws.bufferedRead = ws.bufferedRead[n:] + } + return n, nil +} + +func (ws *wsConnAdapter) Write(b []byte) (int, error) { + maybeErr := ws.ctx.Err() + + n := len(b) + if n == 0 { + return n, maybeErr + } + if maybeErr != nil { + return 0, maybeErr + } + if err := ws.conn.Write(ws.ctx, websocket.MessageBinary, b[:n]); err != nil { + ws.recordError(ws.ctx, "websocket_write_error") + log.Warnf("WebSocket write error for %s: %v", ws.clientAddr, err) + return 0, err // we don't know how many bytes have been written + } + + ws.recordBytesTransferred(ws.ctx, "grpc_to_ws", n) + return n, nil +} + +func (ws *wsConnAdapter) Close() error { + ws.closed.Store(true) + return ws.conn.Close(websocket.StatusNormalClosure, "") +} + +func (ws *wsConnAdapter) LocalAddr() net.Addr { return wsAddr{ws.prefix} } +func (ws *wsConnAdapter) RemoteAddr() net.Addr { return wsAddr{ws.prefix} } + +func (ws *wsConnAdapter) SetDeadline(t time.Time) error { + return nil +} + +func (ws *wsConnAdapter) SetReadDeadline(t time.Time) error { + return nil +} + +func (ws *wsConnAdapter) SetWriteDeadline(t time.Time) error { + return nil +} + +func (ws *wsConnAdapter) recordError(ctx context.Context, errorType string) { + if ws.metrics == nil { + return + } + ws.metrics.RecordError(ctx, errorType) +} + +func (ws *wsConnAdapter) recordBytesTransferred(ctx context.Context, direction string, bytes int) { + if ws.metrics == nil { + return + } + ws.metrics.RecordBytesTransferred(ctx, direction, int64(bytes)) +} + +func (ws *wsConnAdapter) IsClosed() bool { + return ws.closed.Load() +} diff --git a/util/wsproxy/server/ws_conn_adapter_test.go b/util/wsproxy/server/ws_conn_adapter_test.go new file mode 100644 index 000000000..5369b2362 --- /dev/null +++ b/util/wsproxy/server/ws_conn_adapter_test.go @@ -0,0 +1,204 @@ +package server + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "math/rand/v2" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "golang.org/x/net/http2" + "golang.org/x/net/http2/hpack" +) + +func TestAdapterHandlingConnectionClosures(t *testing.T) { + var cases = []struct { + description string + casenum int + }{ + {"client-side ws connection is closed", 0}, + {"server-side ws connection is closed", 1}, + {"client-side context is cancelled", 2}, + {"server-side context is cancelled", 3}, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + serversock := filepath.Join("/tmp", "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + t.Cleanup(func() { os.Remove(serversock) }) + + l, err := net.Listen("unix", serversock) + assert.NoError(t, err) + + proxy := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf, _ := io.ReadAll(r.Body) + defer r.Body.Close() + w.Write([]byte("echo: " + string(buf))) //nolint:errcheck + })) + + handler, ok := proxy.Handler().(*proxyHandler) + assert.True(t, ok) + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + httpServer := http.Server{ + Handler: handler, + } + go httpServer.Serve(l) //nolint:errcheck + t.Cleanup(func() { httpServer.Close() }) + + clientconn, _, err := websocket.Dial(context.Background(), "http://whatever", //nolint:bodyclose + &websocket.DialOptions{HTTPClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", serversock) + }, + }}}) + assert.NoError(t, err) + + clientCtx, cancel := context.WithCancel(context.Background()) //nolint:govet + h2client := &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(_ context.Context, _, _ string, _ *tls.Config) (net.Conn, error) { + return &wsConnAdapter{ + prefix: "test-client", + ctx: clientCtx, + conn: clientconn, + }, nil + }, + }} + + resp, err := h2client.Post("http://whatever", "text/html", strings.NewReader("g'day")) + assert.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + defer resp.Body.Close() + + assert.NoError(t, err) + assert.Equal(t, "echo: g'day", string(body)) + + switch c.casenum { + case 0: + clientconn.Close(websocket.StatusNormalClosure, "") + case 1: + handler.conn.Load().Close() + case 2: + cancel() + case 3: + resp.Body.Close() + h2client.CloseIdleConnections() + } + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, handler.conn.Load().IsClosed()) + }, 3*time.Second, 100*time.Millisecond) + }) //nolint:govet + } +} + +func TestAdapterHandlingHttpConnection_NoHeadersSent(t *testing.T) { + t.Skip("currently disabled as it requires idle timeout to be set") + + serversock := filepath.Join("/tmp", "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + defer os.Remove(serversock) + + l, err := net.Listen("unix", serversock) + assert.NoError(t, err) + + proxy := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf, _ := io.ReadAll(r.Body) + defer r.Body.Close() //nolint:errcheck + w.Write([]byte("echo: " + string(buf))) //nolint:errcheck + })) + + handler, ok := proxy.Handler().(*proxyHandler) + assert.True(t, ok) + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + httpServer := http.Server{ + Handler: handler, + } + go httpServer.Serve(l) //nolint:errcheck + + clientconn, _, err := websocket.Dial(context.Background(), "http://whatever", //nolint:bodyclose + &websocket.DialOptions{HTTPClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", serversock) + }, + }}}) + assert.NoError(t, err) + + h2client := &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(_ context.Context, _, _ string, _ *tls.Config) (net.Conn, error) { + return &h2ConnectionSnooper{wrappedConn: &wsConnAdapter{ + prefix: "test-client", + ctx: context.Background(), + conn: clientconn, + }, shouldDropFrame: func(f http2.FrameType) bool { return f == http2.FrameHeaders || f == http2.FrameData }}, nil + }, + }} + + _, err = h2client.Post("http://whatever", "text/html", strings.NewReader("g'day")) + assert.Error(t, err) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, handler.conn.Load().IsClosed()) + }, 3*time.Second, 100*time.Millisecond) +} + +type h2ConnectionSnooper struct { + wrappedConn net.Conn + shouldDropFrame func(f http2.FrameType) bool +} + +func (hs *h2ConnectionSnooper) Read(b []byte) (n int, err error) { + return hs.wrappedConn.Read(b) +} + +func (hs *h2ConnectionSnooper) Write(b []byte) (n int, err error) { + fr := http2.NewFramer(nil, bytes.NewReader(b)) + fr.ReadMetaHeaders = hpack.NewDecoder(0, nil) + f, err := fr.ReadFrame() + if err != nil { + return hs.wrappedConn.Write(b) + } + + if hs.shouldDropFrame != nil && hs.shouldDropFrame(f.Header().Type) { + return len(b), nil + } + + return hs.wrappedConn.Write(b) +} + +func (hs *h2ConnectionSnooper) Close() error { return hs.wrappedConn.Close() } + +func (hs *h2ConnectionSnooper) LocalAddr() net.Addr { return hs.wrappedConn.LocalAddr() } + +func (hs *h2ConnectionSnooper) RemoteAddr() net.Addr { return hs.wrappedConn.RemoteAddr() } + +func (hs *h2ConnectionSnooper) SetDeadline(t time.Time) error { return hs.wrappedConn.SetDeadline(t) } + +func (hs *h2ConnectionSnooper) SetReadDeadline(t time.Time) error { + return hs.wrappedConn.SetReadDeadline(t) +} + +func (hs *h2ConnectionSnooper) SetWriteDeadline(t time.Time) error { + return hs.wrappedConn.SetWriteDeadline(t) +} From e704203927fcd40ee2b4687a134d513c3909f3bf Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Thu, 10 Sep 2026 20:05:42 +0200 Subject: [PATCH 27/63] [management] do not hard-code tmp dir path in ws_conn_adapter_test (#7503) * do not hard-code tmp dir path Signed-off-by: Dmitri Dolguikh * use os.TempDir to get tmp dir Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- util/wsproxy/server/ws_conn_adapter_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/wsproxy/server/ws_conn_adapter_test.go b/util/wsproxy/server/ws_conn_adapter_test.go index 5369b2362..d46e4830b 100644 --- a/util/wsproxy/server/ws_conn_adapter_test.go +++ b/util/wsproxy/server/ws_conn_adapter_test.go @@ -34,7 +34,7 @@ func TestAdapterHandlingConnectionClosures(t *testing.T) { for _, c := range cases { t.Run(c.description, func(t *testing.T) { - serversock := filepath.Join("/tmp", "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + serversock := filepath.Join(os.TempDir(), "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") t.Cleanup(func() { os.Remove(serversock) }) l, err := net.Listen("unix", serversock) @@ -111,7 +111,7 @@ func TestAdapterHandlingConnectionClosures(t *testing.T) { func TestAdapterHandlingHttpConnection_NoHeadersSent(t *testing.T) { t.Skip("currently disabled as it requires idle timeout to be set") - serversock := filepath.Join("/tmp", "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + serversock := filepath.Join(os.TempDir(), "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") defer os.Remove(serversock) l, err := net.Listen("unix", serversock) From 2f48dbea6ae4d07411e37283008d63534803e37a Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Thu, 10 Sep 2026 21:41:59 +0200 Subject: [PATCH 28/63] [client] Add a release-wired rootless UBI image variant (#7469) * [client] Add a release-wired rootless UBI image variant * [client] Add ARM64 to the rootless UBI image * [client] Express license output validation as a guard --- .goreleaser.yaml | 37 ++++++++++++++++ client/Dockerfile-rootless.ubi | 45 ++++++++++++++++++++ client/collect-licenses.sh | 77 ++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 client/Dockerfile-rootless.ubi create mode 100644 client/collect-licenses.sh diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c5d260376..778ccb892 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -289,6 +289,43 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: netbird-rootless-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "{{ .Version }}-rootless-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-ubi-latest{{ end }}" + dockerfile: client/Dockerfile-rootless.ubi + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=0 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: relay disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/client/Dockerfile-rootless.ubi b/client/Dockerfile-rootless.ubi new file mode 100644 index 000000000..4701728c1 --- /dev/null +++ b/client/Dockerfile-rootless.ubi @@ -0,0 +1,45 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-rootless" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Rootless Client" \ + description="NetBird connects devices through an encrypted overlay using userspace networking without a TUN device or network administration capabilities." + +RUN microdnf install -y bash ca-certificates && microdnf clean all + +COPY --chmod=0555 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh +COPY --chmod=0555 ${NETBIRD_BINARY} /usr/local/bin/netbird +COPY licenses/ /licenses/ +# Only application storage is group-writable for arbitrary non-root UIDs. +# Runtime-created credentials keep the client's restrictive file modes. +RUN mkdir -p /var/lib/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +WORKDIR /var/lib/netbird +USER 1000:0 + +ENV \ + HOME="/var/lib/netbird" \ + NETBIRD_BIN="/usr/local/bin/netbird" \ + NB_USE_NETSTACK_MODE="true" \ + NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \ + NB_CONFIG="/var/lib/netbird/config.json" \ + NB_STATE_DIR="/var/lib/netbird" \ + NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \ + NB_LOG_FILE="console,/var/lib/netbird/client.log" \ + NB_DISABLE_DNS="true" \ + NB_ENABLE_CAPTURE="false" \ + NB_ENTRYPOINT_SERVICE_TIMEOUT="30" + +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/netbird-entrypoint.sh"] diff --git a/client/collect-licenses.sh b/client/collect-licenses.sh new file mode 100644 index 000000000..7dfabada9 --- /dev/null +++ b/client/collect-licenses.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 + exit 2 +fi + +repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +output_name=$(basename "$1") +if [ -z "$output_name" ] || [ "$output_name" = "." ] || + [ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then + printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 + exit 2 +fi +output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) +output="$output_parent/$output_name" +shift +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX") +trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM + +if [ -e "$output" ] || [ -L "$output" ]; then + printf 'output directory already exists: %s\n' "$output" >&2 + exit 1 +fi +mkdir "$output" +mkdir "$output/third_party" + +cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt" + +cd "$repo_root" +for arch in "$@"; do + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ + go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules" +done +LC_ALL=C sort -u "$modules" >"$sorted_modules" + +goroot=$(go env GOROOT) +for term in LICENSE PATENTS; do + if [ ! -f "$goroot/$term" ]; then + printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 + exit 1 + fi + cp "$goroot/$term" "$output/Go-$term" +done + +while IFS=' ' read -r module version module_dir; do + [ -n "$module" ] || continue + [ "$module" = "github.com/netbirdio/netbird" ] && continue + + if [ -z "$version" ] || [ ! -d "$module_dir" ]; then + printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 + exit 1 + fi + + destination="$output/third_party/$module/$version" + mkdir -p "$destination" + printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" + + found=false + for term in \ + "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ + "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ + "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ + "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ + "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do + [ -f "$term" ] || continue + cp "$term" "$destination/" + found=true + done + + if [ "$found" = false ]; then + printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 + exit 1 + fi +done <"$sorted_modules" From a419e770d9750caf4ea7c525056c9d2a60bd78b6 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:38:22 +0200 Subject: [PATCH 29/63] [client, proxy] Make the buffer-pool retune reachable while a device is stalled (#7452) * [client] Track the WireGuard device on the engine as a lock-free handle Add an atomic handle on the wg device next to wgInterface, stored once the interface is up and cleared when it is closed. Nothing reads it yet, so this is a pure addition with no behavior change; it exists so the next commit can reach the device without taking syncMsgMux. * [client] Retune the WireGuard buffer pool without the engine lock SetPerformance took syncMsgMux before reaching the device. That lock is held by handleSync while it adds and removes peers, and peer removal is exactly what blocks when a device's buffer pool is exhausted: Peer.Stop waits on a keepalive timer callback that is itself parked in WaitPool.Get. Raising the cap is the way out of that state, so the call must not queue behind the lock the stall is holding. Read the device through the atomic handle instead. Device.SetPreallocatedBuffersPerPool takes the pool's own lock and broadcasts, so the waiters wake up. * [proxy] Extract the buffer-cap apply loop out of the perf handler Pure move: the loop over the registered clients becomes applyBufferCap, with the same sequential behavior and the same return values. Split out so the next commit can change how it iterates without the diff also carrying the move. * [proxy] Bound the perf endpoint so one wedged client cannot hold it The apply loop was sequential and unbounded. embed.Client.SetPerformance goes through the client lock, which Start holds for the whole of a startup, so a single account that is busy or wedged delayed the new buffer cap for every other account on the node -- on the endpoint whose whole purpose is to un-wedge a node. Apply to all clients concurrently and give the whole call a 5s budget. Accounts that do not answer in time are reported in "failed" instead of blocking the response. * [client] Drop the device handle before closing the interface close() cleared the atomic handle only after wgInterface.Close() returned, so a concurrent SetPerformance could still load it, retune a device that is being torn down, and report the change as applied for an engine that has stopped. Clear it first, so the window closes before the teardown begins. Reported by cubic on PR #7452. * [proxy] Put the per-client retune behind a field Pure refactor: applyBufferCap calls h.setPerformance instead of the client method directly, and NewHandler wires it to setClientPerformance. Same call, same behavior; the seam is what lets the next two commits be tested without a live embedded client. * [proxy] Do not report a finished retune as timed out When the deadline fires, select chooses at random among the ready cases, so a result already sitting in the buffered channel could be skipped and its account reported as timed out even though the cap had been applied. Drain what is buffered before declaring the rest pending. Reported by cubic on PR #7452. * [proxy] Keep one retune per account in flight The 5s budget bounds how long the endpoint waits, not the work: SetPerformance goes through the embedded client's lock, and on a wedged account Stop holds that lock forever, so every retry left one more goroutine parked there. Route each account through a single worker. A request that finds one already running takes its result if it has landed, and otherwise reports the account under "in_flight" instead of starting a second attempt. One stuck account now costs one goroutine, no matter how often the endpoint is called. Reported by CodeRabbit and cubic on PR #7452. * [proxy] Make the retune budget a var Pure refactor: perfApplyTimeout becomes a var so a test can shorten it instead of waiting five seconds. Same value, same behavior in production. * [proxy] Extract the buffered-result drain Pure refactor: the loop that empties the results channel when the deadline fires becomes collectBuffered. Same behavior; split out so it can be tested on its own, which the inline version could not be without racing the deadline. * [proxy] Cover the retune single-flight and the deadline drain TestApplyBufferCapSingleFlightPerAccount fails without the worker registry: five calls against a client stuck in its own lock start five blocked workers instead of one. TestCollectBufferedCountsResultsReadyAtTheDeadline pins the drain helper's contract - buffered results counted, errors recorded, only unanswered accounts left pending. It drives collectBuffered directly: through applyBufferCap the two select cases race by construction, so an end-to-end version of it would pass on the unfixed code about half the time. * [proxy] Keep the worker alongside each pending account Pure refactor: the pending set becomes a map to the account's worker instead of an empty struct. Same membership and same behavior; the next commit needs the worker to resolve an account whose result has not reached the channel yet. * [proxy] Publish a retune result before releasing its slot The worker sent its result last, after taking perfMu to remove itself from the registry. That lock is taken once per account by every caller walking the fleet, so a worker that finished on time could queue behind an apply over thousands of accounts and land after the deadline. Send first, deregister after. Reported by cubic on PR #7452. * [proxy] Read the worker, not the clock, for a finished retune Publishing earlier only narrows the window: a client that answers just before the deadline can still be reported as timed out. At the deadline the workers themselves are authoritative - a closed done channel means the retune finished and w.err carries its outcome, ordered by the close. Consult them instead of declaring every pending account timed out, and keep the timeout label for the ones actually still running. Reported by cubic on PR #7452. * [proxy] Cover the finished-worker resolution at the deadline Fails on the previous behavior with "applied = 0, want 1": every pending account was labelled a timeout, including the one whose retune had already completed. --- client/internal/engine.go | 28 +++-- proxy/internal/debug/handler.go | 189 ++++++++++++++++++++++++++++-- proxy/internal/debug/perf_test.go | 158 +++++++++++++++++++++++++ 3 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 proxy/internal/debug/perf_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index f8b65f7d8..d517d1d68 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -14,12 +14,14 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/hashicorp/go-multierror" "github.com/pion/ice/v4" "github.com/pion/stun/v3" log "github.com/sirupsen/logrus" + wgdevice "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" @@ -236,6 +238,12 @@ type Engine struct { wgInterface WGIface + // wgDevice is a lock-free handle on the WireGuard device behind + // wgInterface. Reaching the device through wgInterface requires + // syncMsgMux, which handleSync holds while it adds and removes peers; + // SetPerformance must stay reachable exactly when that work is stuck. + wgDevice atomic.Pointer[wgdevice.Device] + udpMux *udpmux.UniversalUDPMuxDefault // networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service @@ -651,6 +659,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error()) return fmt.Errorf("up wg interface: %w", err) } + e.wgDevice.Store(e.wgInterface.GetWGDevice()) // Set up notrack rules immediately after proxy is listening to prevent // conntrack entries from being created before the rules are in place @@ -2144,6 +2153,10 @@ func (e *Engine) close() { log.Debugf("removing Netbird interface %s", e.config.WgIfaceName) if e.wgInterface != nil { + // Drop the handle before the close starts: a retune that loads it + // afterwards would touch a device on its way out and report success + // for an engine that is already gone. + e.wgDevice.Store(nil) if err := e.wgInterface.Close(); err != nil { log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err) } @@ -2303,15 +2316,16 @@ type Performance struct { } // SetPerformance applies the given tuning to this engine's live Device. +// +// It deliberately does not take syncMsgMux. Raising the buffer pool cap is the +// recovery path for a device whose pool is exhausted, and an exhausted pool +// blocks peer removal inside handleSync, which holds syncMsgMux for as long as +// it stays blocked. Taking the lock here would make the retune unreachable in +// the one situation that needs it. func (e *Engine) SetPerformance(t Performance) error { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - if e.wgInterface == nil { - return fmt.Errorf("wg interface not initialized") - } - dev := e.wgInterface.GetWGDevice() + dev := e.wgDevice.Load() if dev == nil { - return fmt.Errorf("wg device not initialized") + return errors.New("wg device not initialized") } if t.PreallocatedBuffersPerPool != nil { dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool) diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go index 6300228d7..960c3e089 100644 --- a/proxy/internal/debug/handler.go +++ b/proxy/internal/debug/handler.go @@ -105,6 +105,20 @@ type Handler struct { startTime time.Time templates *template.Template templateMu sync.RWMutex + + // setPerformance applies a buffer cap to one client. Held as a field so + // tests can drive applyBufferCap without a live embedded client. + setPerformance func(*nbembed.Client, uint32) error + + perfMu sync.Mutex + perfInflight map[types.AccountID]*perfWorker +} + +// perfWorker is the single in-flight retune for one account. err is valid once +// done is closed. +type perfWorker struct { + done chan struct{} + err error } // NewHandler creates a new debug handler. @@ -113,10 +127,11 @@ func NewHandler(provider clientProvider, healthChecker healthChecker, logger *lo logger = log.StandardLogger() } h := &Handler{ - provider: provider, - health: healthChecker, - logger: logger, - startTime: time.Now(), + provider: provider, + health: healthChecker, + logger: logger, + startTime: time.Now(), + setPerformance: setClientPerformance, } if err := h.loadTemplates(); err != nil { logger.Errorf("failed to load embedded templates: %v", err) @@ -716,15 +731,7 @@ func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) { } capN := uint32(n) - applied := 0 - failed := map[string]string{} - for accountID, client := range h.provider.ListClientsForStartup() { - if err := client.SetPerformance(nbembed.Performance{PreallocatedBuffersPerPool: &capN}); err != nil { - failed[string(accountID)] = err.Error() - continue - } - applied++ - } + applied, failed, inFlight := h.applyBufferCap(capN) resp := map[string]any{ "success": true, @@ -734,9 +741,165 @@ func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) { if len(failed) > 0 { resp["failed"] = failed } + if len(inFlight) > 0 { + resp["in_flight"] = inFlight + } h.writeJSON(w, resp) } +// perfApplyTimeout bounds the whole apply, however many clients are registered. +// A var, not a const, so tests can shorten the wait. +var perfApplyTimeout = 5 * time.Second + +type perfResult struct { + accountID types.AccountID + err error +} + +// setClientPerformance is the production implementation behind Handler.setPerformance. +func setClientPerformance(client *nbembed.Client, capN uint32) error { + return client.SetPerformance(nbembed.Performance{PreallocatedBuffersPerPool: &capN}) +} + +// collectBuffered takes every result already sitting in the channel, removing +// those accounts from pending, and returns how many of them succeeded. It is +// called when the deadline fires: select picks at random among ready cases, so +// a result that landed in time would otherwise be reported as a timeout. +func collectBuffered(results <-chan perfResult, pending map[types.AccountID]*perfWorker, failed map[string]string) int { + applied := 0 + for { + select { + case res := <-results: + delete(pending, res.accountID) + if res.err != nil { + failed[string(res.accountID)] = res.err.Error() + continue + } + applied++ + default: + return applied + } + } +} + +// resolvePending closes out the accounts still pending when the deadline fires. +// A worker whose done channel is closed has finished, whatever the results +// channel has managed to deliver, so its own error is the truth; the rest are +// genuinely still running and are reported as timed out. Returns how many of +// them had in fact succeeded. +func resolvePending(pending map[types.AccountID]*perfWorker, failed map[string]string) int { + applied := 0 + for accountID, w := range pending { + select { + case <-w.done: + if w.err != nil { + failed[string(accountID)] = w.err.Error() + continue + } + applied++ + default: + failed[string(accountID)] = fmt.Sprintf("timed out after %s waiting for the client", perfApplyTimeout) + } + } + return applied +} + +// startPerfWorker returns the in-flight retune for the account, starting one if +// there is none. The bool reports whether this call started it. +// +// At most one retune runs per account at a time. A client wedged inside its own +// lock never returns, so without this a caller could add one permanently blocked +// goroutine per request just by retrying the endpoint. +func (h *Handler) startPerfWorker(accountID types.AccountID, client *nbembed.Client, capN uint32, results chan<- perfResult) (*perfWorker, bool) { + h.perfMu.Lock() + defer h.perfMu.Unlock() + + if w, ok := h.perfInflight[accountID]; ok { + return w, false + } + + w := &perfWorker{done: make(chan struct{})} + if h.perfInflight == nil { + h.perfInflight = make(map[types.AccountID]*perfWorker) + } + h.perfInflight[accountID] = w + + go func() { + err := h.setPerformance(client, capN) + w.err = err + close(w.done) + + // Publish before touching the registry: perfMu is taken once per + // account by every caller walking the fleet, so a finishing worker + // can queue behind a long apply and miss its own deadline. + results <- perfResult{accountID: accountID, err: err} + + h.perfMu.Lock() + delete(h.perfInflight, accountID) + h.perfMu.Unlock() + }() + + return w, true +} + +// applyBufferCap sets the WireGuard buffer pool cap on every registered client +// and reports how many took it, a per-account error for those that did not, and +// the accounts whose earlier retune has not come back yet. +// +// Clients are handled concurrently and the wait is bounded: SetPerformance goes +// through the embedded client's lock, which Start and Stop hold for as long as +// they take - and on a wedged client Stop never returns. This endpoint is the +// recovery path for exactly that fleet, so one stuck account must neither delay +// the others nor accumulate goroutines across retries. +func (h *Handler) applyBufferCap(capN uint32) (int, map[string]string, []string) { + clients := h.provider.ListClientsForStartup() + results := make(chan perfResult, len(clients)) + + applied := 0 + failed := map[string]string{} + var inFlight []string + pending := make(map[types.AccountID]*perfWorker, len(clients)) + + for accountID, client := range clients { + w, started := h.startPerfWorker(accountID, client, capN, results) + if started { + pending[accountID] = w + continue + } + // Another request owns this account's retune. Take its result if it + // has already landed, otherwise report it as still running instead of + // waiting on it again. + select { + case <-w.done: + if w.err != nil { + failed[string(accountID)] = w.err.Error() + continue + } + applied++ + default: + inFlight = append(inFlight, string(accountID)) + } + } + + deadline := time.After(perfApplyTimeout) + for range len(pending) { + select { + case res := <-results: + delete(pending, res.accountID) + if res.err != nil { + failed[string(res.accountID)] = res.err.Error() + continue + } + applied++ + case <-deadline: + applied += collectBuffered(results, pending, failed) + applied += resolvePending(pending, failed) + return applied, failed, inFlight + } + } + return applied, failed, inFlight +} + // handleRuntime returns cheap runtime and process stats. Safe to hit on a // running proxy; does not read pprof profiles. func (h *Handler) handleRuntime(w http.ResponseWriter, _ *http.Request) { diff --git a/proxy/internal/debug/perf_test.go b/proxy/internal/debug/perf_test.go new file mode 100644 index 000000000..abcfccb50 --- /dev/null +++ b/proxy/internal/debug/perf_test.go @@ -0,0 +1,158 @@ +package debug + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + nbembed "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// perfProvider serves a fixed set of accounts. The clients are nil: the tests +// drive Handler.setPerformance, which never dereferences them. +type perfProvider struct { + accounts []types.AccountID +} + +func (p *perfProvider) GetClient(types.AccountID) (*nbembed.Client, bool) { return nil, false } + +func (p *perfProvider) ListClientsForDebug() map[types.AccountID]roundtrip.ClientDebugInfo { + return nil +} + +func (p *perfProvider) ListClientsForStartup() map[types.AccountID]*nbembed.Client { + out := make(map[types.AccountID]*nbembed.Client, len(p.accounts)) + for _, id := range p.accounts { + out[id] = nil + } + return out +} + +type stubHealth struct{} + +func (stubHealth) ReadinessProbe() bool { return true } +func (stubHealth) StartupProbe(context.Context) bool { return true } +func (stubHealth) CheckClientsConnected(context.Context) (bool, map[types.AccountID]health.ClientHealth) { + return true, nil +} + +func shortenPerfTimeout(t *testing.T, d time.Duration) { + t.Helper() + prev := perfApplyTimeout + perfApplyTimeout = d + t.Cleanup(func() { perfApplyTimeout = prev }) +} + +// TestCollectBufferedCountsResultsReadyAtTheDeadline covers the select-ordering +// trap: when the deadline fires, results already buffered must be counted, not +// reported as timeouts. Driving collectBuffered directly keeps it deterministic +// - through applyBufferCap the two select cases race by construction. +func TestCollectBufferedCountsResultsReadyAtTheDeadline(t *testing.T) { + results := make(chan perfResult, 3) + results <- perfResult{accountID: "ok"} + results <- perfResult{accountID: "broken", err: errors.New("boom")} + + pending := map[types.AccountID]*perfWorker{ + "ok": {done: make(chan struct{})}, + "broken": {done: make(chan struct{})}, + "wedged": {done: make(chan struct{})}, + } + failed := map[string]string{} + + applied := collectBuffered(results, pending, failed) + + if applied != 1 { + t.Fatalf("applied = %d, want 1", applied) + } + if failed["broken"] != "boom" { + t.Fatalf("failed = %v, want the error recorded for \"broken\"", failed) + } + if _, ok := pending["wedged"]; !ok || len(pending) != 1 { + t.Fatalf("pending = %v, want only the account that never answered", pending) + } +} + +// TestApplyBufferCapSingleFlightPerAccount covers the goroutine accumulation +// reported on PR #7452: repeated calls against a client stuck in its own lock +// must not start a second attempt for the same account. +func TestApplyBufferCapSingleFlightPerAccount(t *testing.T) { + shortenPerfTimeout(t, 50*time.Millisecond) + + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + + var calls atomic.Int32 + h := &Handler{ + provider: &perfProvider{accounts: []types.AccountID{"wedged"}}, + health: stubHealth{}, + setPerformance: func(_ *nbembed.Client, _ uint32) error { + calls.Add(1) + <-release + return nil + }, + } + + for i := range 5 { + applied, failed, inFlight := h.applyBufferCap(4096) + if applied != 0 { + t.Fatalf("call %d: applied = %d, want 0", i, applied) + } + if i == 0 { + if len(failed) != 1 { + t.Fatalf("first call: failed = %v, want the account reported as timed out", failed) + } + continue + } + if len(inFlight) != 1 { + t.Fatalf("call %d: inFlight = %v, want the account reported as still running", i, inFlight) + } + if len(failed) != 0 { + t.Fatalf("call %d: failed = %v, want empty while the retune is in flight", i, failed) + } + } + + if got := calls.Load(); got != 1 { + t.Fatalf("setPerformance called %d times, want 1: each retry started another blocked worker", got) + } +} + +// TestResolvePendingTrustsFinishedWorkers covers the reporting race cubic +// flagged on PR #7452: a retune that finished just before the deadline must be +// reported by its outcome, not as a timeout, whatever the results channel has +// delivered so far. +func TestResolvePendingTrustsFinishedWorkers(t *testing.T) { + ok := &perfWorker{done: make(chan struct{})} + close(ok.done) + + broken := &perfWorker{done: make(chan struct{}), err: errors.New("boom")} + close(broken.done) + + stillRunning := &perfWorker{done: make(chan struct{})} + + pending := map[types.AccountID]*perfWorker{ + "ok": ok, + "broken": broken, + "running": stillRunning, + } + failed := map[string]string{} + + applied := resolvePending(pending, failed) + + if applied != 1 { + t.Fatalf("applied = %d, want 1", applied) + } + if failed["broken"] != "boom" { + t.Fatalf("failed[broken] = %q, want the worker's own error", failed["broken"]) + } + if _, ok := failed["ok"]; ok { + t.Fatalf("failed = %v, want no entry for the account that succeeded", failed) + } + if got := failed["running"]; got == "" || got == "boom" { + t.Fatalf("failed[running] = %q, want the timeout message", got) + } +} From add8a75981b84375c3cfca5cb23f33f41d77e1b5 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:49:20 +0200 Subject: [PATCH 30/63] [management] validate peer existence when adding to group (#7486) --- management/server/group.go | 30 +++++++-- management/server/group_test.go | 81 ++++++++++++++++++++++- management/server/store/sql_store.go | 4 +- management/server/store/sql_store_test.go | 14 ++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/management/server/group.go b/management/server/group.go index 33870f25e..ca20a6b08 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -101,10 +101,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use return status.Errorf(status.Internal, "failed to create group: %v", err) } - for _, peerID := range newGroup.Peers { - if err := transaction.AddPeerToGroup(ctx, accountID, peerID, newGroup.ID); err != nil { - return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, newGroup.ID, err) - } + if err = syncGroupMembership(ctx, transaction, accountID, newGroup.ID, newGroup.Peers, nil); err != nil { + return err } snap, err = affectedpeers.Load(ctx, transaction, accountID, change) @@ -200,6 +198,9 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use // syncGroupMembership applies the peer membership delta for a group within a transaction. func syncGroupMembership(ctx context.Context, transaction store.Store, accountID, groupID string, peersToAdd, peersToRemove []string) error { + if err := validateGroupPeers(ctx, transaction, accountID, peersToAdd); err != nil { + return err + } for _, peerID := range peersToAdd { if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, groupID, err) @@ -213,6 +214,25 @@ func syncGroupMembership(ctx context.Context, transaction store.Store, accountID return nil } +func validateGroupPeers(ctx context.Context, transaction store.Store, accountID string, peerIDs []string) error { + if len(peerIDs) == 0 { + return nil + } + + peers, err := transaction.GetPeersByIDs(ctx, store.LockingStrengthNone, accountID, peerIDs) + if err != nil { + return err + } + + for _, peerID := range peerIDs { + if _, ok := peers[peerID]; !ok { + return status.Errorf(status.InvalidArgument, "peer with ID %s not found", peerID) + } + } + + return nil +} + // CreateGroups adds new groups to the account. // Note: This function does not acquire the global lock. // It is the caller's responsibility to ensure proper locking is in place before invoking this method. @@ -540,7 +560,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}} err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { + if err := syncGroupMembership(ctx, transaction, accountID, groupID, []string{peerID}, nil); err != nil { return err } diff --git a/management/server/group_test.go b/management/server/group_test.go index f5aeceea8..da056c8a9 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" @@ -1236,3 +1236,82 @@ func Test_IncrementNetworkSerial(t *testing.T) { assert.Equal(t, totalPeers, int(account.Network.Serial), "Expected %d serial increases in account %s, got %d", totalPeers, accountID, account.Network.Serial) } + +func TestDefaultAccountManager_GroupPeersMustBelongToAccount(t *testing.T) { + manager, _, account, peer1, _, _ := setupNetworkMapTest(t) + + otherAccount, err := createAccount(manager, "other_account", "other_user", "") + require.NoError(t, err) + + foreignPeer := &peer2.Peer{ + ID: "foreign-peer", + AccountID: otherAccount.Id, + Key: "foreign-key", + DNSLabel: "foreign-peer", + IP: uint32ToIP(1), + } + require.NoError(t, manager.Store.AddPeerToAccount(context.Background(), foreignPeer)) + + assertRejected := func(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + s, ok := status.FromError(err) + require.True(t, ok, "expected status error, got %v", err) + assert.Equal(t, status.InvalidArgument, s.Type(), "peer outside the account should be rejected as invalid argument") + } + + t.Run("create rejects foreign peer", func(t *testing.T) { + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + Name: "foreign", + Issued: types.GroupIssuedAPI, + Peers: []string{peer1.ID, foreignPeer.ID}, + }) + assertRejected(t, err) + + _, err = manager.Store.GetGroupByName(context.Background(), store.LockingStrengthNone, account.Id, "foreign") + assert.Error(t, err, "rejected create must not persist the group") + }) + + t.Run("update rejects foreign and unknown peers", func(t *testing.T) { + group := &types.Group{ID: "own", Name: "own", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}} + require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group)) + + group.Peers = []string{peer1.ID, foreignPeer.ID} + assertRejected(t, manager.UpdateGroup(context.Background(), account.Id, userID, group)) + + group.Peers = []string{peer1.ID, "does-not-exist"} + assertRejected(t, manager.UpdateGroup(context.Background(), account.Id, userID, group)) + + stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID) + require.NoError(t, err) + assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected updates must not change membership") + }) + + t.Run("update tolerates and drops pre-existing dangling members", func(t *testing.T) { + group := &types.Group{ID: "polluted", Name: "polluted", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}} + require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group)) + require.NoError(t, manager.Store.AddPeerToGroup(context.Background(), account.Id, foreignPeer.ID, group.ID)) + + group.Peers = []string{peer1.ID, foreignPeer.ID} + assert.NoError(t, manager.UpdateGroup(context.Background(), account.Id, userID, group), "keeping an existing member must not be rejected") + + group.Peers = []string{peer1.ID} + require.NoError(t, manager.UpdateGroup(context.Background(), account.Id, userID, group)) + + stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID) + require.NoError(t, err) + assert.Equal(t, []string{peer1.ID}, stored.Peers, "dangling member should be removed once omitted") + }) + + t.Run("direct add rejects foreign and unknown peers", func(t *testing.T) { + group := &types.Group{ID: "direct", Name: "direct", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}} + require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group)) + + assertRejected(t, manager.GroupAddPeer(context.Background(), account.Id, group.ID, foreignPeer.ID)) + assertRejected(t, manager.GroupAddPeer(context.Background(), account.Id, group.ID, "does-not-exist")) + + stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID) + require.NoError(t, err) + assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected direct adds must not change membership") + }) +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index ef353ea83..33c723a8a 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -3473,7 +3473,7 @@ func (s *SqlStore) GetPeerGroups(ctx context.Context, lockStrength LockingStreng var groups []*types.Group query := tx. Joins("JOIN group_peers ON group_peers.group_id = groups.id"). - Where("group_peers.peer_id = ?", peerId). + Where("groups.account_id = ? AND group_peers.peer_id = ?", accountId, peerId). Preload(clause.Associations). Find(&groups) @@ -5053,7 +5053,7 @@ func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gro Select("DISTINCT peer_id"). Where("account_id = ? AND group_id IN ?", accountID, groupIDs) - result := s.db.Where("id IN (?)", peerIDsSubquery).Find(&peers) + result := s.db.Where("account_id = ? AND id IN (?)", accountID, peerIDsSubquery).Find(&peers) if result.Error != nil { log.WithContext(ctx).Errorf("failed to get peers by group IDs: %s", result.Error) return nil, status.Errorf(status.Internal, "failed to get peers by group IDs") diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 4b7bcf068..fbcff5257 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -2844,6 +2844,14 @@ func TestSqlStore_GetPeerGroups(t *testing.T) { groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID) require.NoError(t, err) assert.Len(t, groups, 2) + + foreignPeerID := "foreign-peer" + err = store.AddPeerToGroup(context.Background(), accountID, foreignPeerID, "cfefqs706sqkneg59g4h") + require.NoError(t, err) + + groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, "other-account", foreignPeerID) + require.NoError(t, err) + assert.Empty(t, groups, "groups of another account must not be returned") } func TestSqlStore_GetAccountPeers(t *testing.T) { @@ -4039,9 +4047,15 @@ func TestSqlStore_GetPeersByGroupIDs(t *testing.T) { } require.NoError(t, store.CreateGroups(ctx, accountID, groups)) + otherAccount := newAccountWithId(ctx, "other-account", "other-user", "") + require.NoError(t, store.SaveAccount(ctx, otherAccount)) + foreignPeer := &nbpeer.Peer{ID: "foreign-peer", AccountID: otherAccount.Id} + require.NoError(t, store.AddPeerToAccount(ctx, foreignPeer)) + require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group1ID)) require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer2, group1ID)) require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group2ID)) + require.NoError(t, store.AddPeerToGroup(ctx, accountID, foreignPeer.ID, group1ID)) peers, err := store.GetPeersByGroupIDs(ctx, accountID, tt.groupIDs) require.NoError(t, err) From 1047df5fa26dd690ef812bd35a454fd6cd0d68c2 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:50:11 +0200 Subject: [PATCH 31/63] [management] pass tls config for combined server (#7499) --- combined/cmd/root.go | 11 +++--- management/internals/server/server.go | 50 ++++++++++++++++++--------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 3e583ef20..917312e57 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -205,7 +205,7 @@ func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstance metricsServer: metricsServer, } - _, tlsSupport, err := handleTLSConfig(cfg) + tlsConfig, tlsSupport, err := handleTLSConfig(cfg) if err != nil { return nil, fmt.Errorf("failed to setup TLS config: %w", err) } @@ -214,7 +214,7 @@ func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstance return nil, err } - if err := servers.createManagementServer(ctx, cfg); err != nil { + if err := servers.createManagementServer(ctx, cfg, tlsConfig); err != nil { return nil, err } @@ -264,7 +264,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool return nil } -func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig) error { +func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig, tlsConfig *tls.Config) error { if !cfg.Management.Enabled { return nil } @@ -297,7 +297,7 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi LogConfigInfo(mgmtConfig) - s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig) + s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig, tlsConfig) if err != nil { cleanupSTUNListeners(s.stunListeners) return fmt.Errorf("failed to create management server: %w", err) @@ -513,7 +513,7 @@ func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) { return nil, false, nil } -func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (mgmtServer.Server, error) { +func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config, tlsConfig *tls.Config) (mgmtServer.Server, error) { mgmt := cfg.Management // Extract port from listen address @@ -542,6 +542,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m AutoResolveDomains: true, MgmtPort: mgmtPort, MgmtMetricsPort: cfg.Server.MetricsPort, + TLSConfig: tlsConfig, DisableMetrics: mgmt.DisableAnonymousMetrics, DisableGeoliteUpdate: mgmt.DisableGeoliteUpdate, // Always enable user deletion from IDP in combined server (embedded IdP is always enabled) diff --git a/management/internals/server/server.go b/management/internals/server/server.go index 22a61bada..9709d1099 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -74,6 +74,7 @@ type BaseServer struct { grpcExtensions []GRPCExtension listener net.Listener + tlsConfig *tls.Config certManager *autocert.Manager update *version.Update @@ -94,6 +95,7 @@ type Config struct { DisableGeoliteUpdate bool UserDeleteFromIDPEnabled bool AutoResolveDomains bool + TLSConfig *tls.Config } // NewServer initializes and configures a new Server instance @@ -110,6 +112,7 @@ func NewServer(cfg *Config) *BaseServer { disableLegacyManagementPort: cfg.DisableLegacyManagementPort, mgmtMetricsPort: cfg.MgmtMetricsPort, autoResolveDomains: cfg.AutoResolveDomains, + tlsConfig: cfg.TLSConfig, } s.container[ContainerKeyBaseServer] = s @@ -139,21 +142,9 @@ func (s *BaseServer) Start(ctx context.Context) error { } s.EphemeralManager().LoadInitialPeers(srvCtx) - var tlsConfig *tls.Config - tlsEnabled := false - if s.Config.HttpConfig.LetsEncryptDomain != "" { - s.certManager, err = encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain) - if err != nil { - return fmt.Errorf("failed creating LetsEncrypt cert manager: %v", err) - } - tlsEnabled = true - } else if s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "" { - tlsConfig, err = loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey) - if err != nil { - log.WithContext(srvCtx).Errorf("cannot load TLS credentials: %v", err) - return err - } - tlsEnabled = true + tlsEnabled, err := s.setupTLS(srvCtx) + if err != nil { + return err } installationID, err := getInstallationID(srvCtx, s.Store()) @@ -215,8 +206,8 @@ func (s *BaseServer) Start(ctx context.Context) error { log.WithContext(ctx).Infof("running HTTP server (LetsEncrypt challenge handler): %s", cml.Addr().String()) s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil)) } - case tlsConfig != nil: - s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), tlsConfig) + case s.tlsConfig != nil: + s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), s.tlsConfig) if err != nil { return fmt.Errorf("failed creating TLS listener on port %d: %v", s.mgmtPort, err) } @@ -240,6 +231,31 @@ func (s *BaseServer) Start(ctx context.Context) error { return nil } +// setupTLS resolves the listener's TLS source: an injected config wins over the HttpConfig certificate settings +func (s *BaseServer) setupTLS(ctx context.Context) (bool, error) { + switch { + case s.tlsConfig != nil: + return true, nil + case s.Config.HttpConfig.LetsEncryptDomain != "": + certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain) + if err != nil { + return false, fmt.Errorf("failed creating LetsEncrypt cert manager: %v", err) + } + s.certManager = certManager + return true, nil + case s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "": + tlsConfig, err := loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey) + if err != nil { + log.WithContext(ctx).Errorf("cannot load TLS credentials: %v", err) + return false, err + } + s.tlsConfig = tlsConfig + return true, nil + default: + return false, nil + } +} + // Stop attempts a graceful shutdown, waiting up to 5 seconds for active connections to finish func (s *BaseServer) Stop() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) From ad3f570e324c1098d4f035466e77730e7df1b214 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:51:03 +0200 Subject: [PATCH 32/63] [management] validate the domain for the flock in proxy (#7501) --- proxy/internal/acme/locker.go | 16 ++++++++++++---- proxy/internal/acme/locker_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/proxy/internal/acme/locker.go b/proxy/internal/acme/locker.go index 2f0f18885..f42324736 100644 --- a/proxy/internal/acme/locker.go +++ b/proxy/internal/acme/locker.go @@ -2,12 +2,14 @@ package acme import ( "context" + "fmt" "path/filepath" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/proxy/internal/flock" "github.com/netbirdio/netbird/proxy/internal/k8s" + "github.com/netbirdio/netbird/shared/management/domain" ) // certLocker provides distributed mutual exclusion for certificate operations. @@ -74,9 +76,15 @@ func newFlockLocker(certDir string, logger *log.Logger) *flockLocker { return &flockLocker{certDir: certDir, logger: logger} } -// Lock acquires an advisory file lock for the given domain. -func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) { - lockPath := filepath.Join(l.certDir, domain+".lock") +// Lock acquires an advisory file lock for the given domain. The domain must +// be a valid hostname so the lock file always resolves to a direct child of +// certDir; anything else is rejected before touching the filesystem. +func (l *flockLocker) Lock(ctx context.Context, name string) (func(), error) { + if !domain.IsValidDomainNoWildcard(name) { + return nil, fmt.Errorf("invalid domain %q for lock file", name) + } + + lockPath := filepath.Join(l.certDir, name+".lock") lockFile, err := flock.Lock(ctx, lockPath) if err != nil { return nil, err @@ -89,7 +97,7 @@ func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) { return func() { if err := flock.Unlock(lockFile); err != nil { - l.logger.Debugf("release cert lock for domain %q: %v", domain, err) + l.logger.Debugf("release cert lock for domain %q: %v", name, err) } }, nil } diff --git a/proxy/internal/acme/locker_test.go b/proxy/internal/acme/locker_test.go index 39245df0c..f131f64f3 100644 --- a/proxy/internal/acme/locker_test.go +++ b/proxy/internal/acme/locker_test.go @@ -63,3 +63,33 @@ func TestNewCertLockerK8sFallsBackToFlock(t *testing.T) { _, ok := locker.(*flockLocker) assert.True(t, ok, "k8s-lease without SA should fall back to flockLocker") } + +func TestFlockLockerRejectsUnsafeDomain(t *testing.T) { + root := t.TempDir() + certDir := filepath.Join(root, "certs") + require.NoError(t, os.Mkdir(certDir, 0o700)) + locker := newFlockLocker(certDir, nil) + + for _, d := range []string{ + "", + ".", + "..", + "../escape", + "../../etc/cron.d/attacker", + "sub/dir.example.com", + `back\slash.example.com`, + "*.example.com", + } { + unlock, err := locker.Lock(context.Background(), d) + assert.Error(t, err, "domain %q", d) + assert.Nil(t, unlock, "domain %q", d) + } + + assert.NoFileExists(t, filepath.Join(root, "escape.lock")) + certEntries, err := os.ReadDir(certDir) + require.NoError(t, err) + assert.Empty(t, certEntries) + rootEntries, err := os.ReadDir(root) + require.NoError(t, err) + assert.Len(t, rootEntries, 1) +} From b57f0e56085bb57d05611b2c378913a2b9098c43 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Fri, 11 Sep 2026 14:20:58 +0200 Subject: [PATCH 33/63] [infrastructure] Preserve snapshot image variant tags (#7511) --- .github/workflows/release.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1bbe9c44..9d3fe3641 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -287,10 +287,15 @@ jobs: image_refs=() tag_and_push() { - local src="$1" img_name tag dst + local src="$1" img_name tag dst variant="" img_name="${src%%:*}" + # Client variants share a repository, so keep their tag suffixes. + case "$src" in + *-rootless-ubi-amd64) variant="-rootless-ubi" ;; + *-rootless-amd64) variant="-rootless" ;; + esac for tag in $(resolve_tags); do - dst="${img_name}:${tag}" + dst="${img_name}:${tag}${variant}" echo "Tagging ${src} -> ${dst}" docker tag "$src" "$dst" docker push "$dst" From 58114f98fb253ea3e4abf6eefe07d3d57b43e5d5 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Fri, 11 Sep 2026 15:41:52 +0300 Subject: [PATCH 34/63] [management] Only trust forwarded-IP headers from configured trusted peers (#7454) --- .../getting-started-enterprise.sh | 5 +- infrastructure_files/getting-started.sh | 29 +++ infrastructure_files/management.json.tmpl | 4 +- management/internals/server/boot.go | 56 ++++-- management/internals/server/realip_test.go | 171 ++++++++++++++++++ 5 files changed, 240 insertions(+), 25 deletions(-) create mode 100644 management/internals/server/realip_test.go diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 3f7cf6357..701598a60 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -808,8 +808,9 @@ server: # Trust X-Forwarded-* only from the Traefik container's static address. Both # keys must stay in step with the ipv4_address pinned in docker-compose.yml: - # trustedPeers decides whether forwarded headers are read at all, and leaving - # it unset falls back to 0.0.0.0/0. + # trustedPeers decides whether forwarded headers are read at all. Leaving it + # unset trusts nothing and records Traefik's own address as every peer's + # connection IP. reverseProxy: trustedPeers: - "${TRAEFIK_IP}/32" diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 5efc0181e..afbc5c282 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -153,6 +153,7 @@ check_domain_resolves() { # NETBIRD_TRAEFIK_CERTRESOLVER external-Traefik cert resolver (type 1) # NETBIRD_BIND_LOCALHOST_ONLY true/false (default true, types 2-5) # NETBIRD_EXTERNAL_PROXY_NETWORK docker network to join (types 2-4) +# NETBIRD_TRUSTED_PEERS reverse proxy address management sees (default: built-in Traefik's IP, empty for types 1-5) # NETBIRD_NON_INTERACTIVE true forces unattended mode even with a TTY # tty_available succeeds only when we may prompt: never when the operator has @@ -459,6 +460,8 @@ initialize_default_values() { MANAGEMENT_HOST_PORT="8081" # Combined server port (management + signal + relay) BIND_LOCALHOST_ONLY="true" EXTERNAL_PROXY_NETWORK="" + TRUSTED_PEERS="" # Address the reverse proxy connects to management from + # Traefik static IP within the internal bridge network TRAEFIK_IP="172.30.0.10" @@ -519,6 +522,7 @@ apply_agent_network_preset() { REVERSE_PROXY_TYPE="0" ENABLE_PROXY="true" ENABLE_CROWDSEC="false" + TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-$TRAEFIK_IP/32}" TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email) @@ -573,6 +577,21 @@ configure_reverse_proxy() { 4) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Caddy") ;; *) ;; # No network prompt for other options esac + + # Only the bundled Traefik has an address we know at render time. External proxies + # must supply the address their proxy reaches management from. + if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then + TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-$TRAEFIK_IP/32}" + else + TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-}" + if [[ -z "$TRUSTED_PEERS" ]]; then + echo "" > /dev/stderr + echo "Note: reverseProxy.trustedPeers is unset, so NetBird will use the address your" > /dev/stderr + echo "proxy connects from as each peer's connection IP. To record real client IPs," > /dev/stderr + echo "set NETBIRD_TRUSTED_PEERS to your proxy's address (e.g. 172.20.0.5/32) and re-run." > /dev/stderr + echo "" > /dev/stderr + fi + fi return 0 } @@ -1033,6 +1052,7 @@ server: reverseProxy: trustedHTTPProxies: - "$TRAEFIK_IP/32" +$(render_trusted_peers) store: engine: "sqlite" @@ -1041,6 +1061,12 @@ EOF return 0 } +render_trusted_peers() { + if [[ -n "$TRUSTED_PEERS" ]]; then + printf ' trustedPeers:\n - "%s"' "$TRUSTED_PEERS" + fi +} + render_dashboard_env() { cat < 0 && trustedProxiesCount > 0 { - log.WithContext(context.Background()).Warn("TrustedHTTPProxies and TrustedHTTPProxiesCount both are configured. " + - "This is not recommended way to extract X-Forwarded-For. Consider using one of these options.") - } - realipOpts := []realip.Option{ - realip.WithTrustedPeers(trustedPeers), - realip.WithTrustedProxies(trustedHTTPProxies), - realip.WithTrustedProxiesCount(trustedProxiesCount), - realip.WithHeaders([]string{realip.XForwardedFor, realip.XRealIp}), - } + realipOpts := realIPOptions(s.Config.ReverseProxy) proxyUnary, proxyStream, proxyAuthClose := nbgrpc.NewProxyAuthInterceptors(s.Store()) s.proxyAuthClose = proxyAuthClose gRPCOpts := []grpc.ServerOption{ @@ -333,7 +318,7 @@ func (s *BaseServer) AccessLogsManager() accesslogs.Manager { }) } -func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) { +func loadTLSConfig(certFile, certKey string) (*tls.Config, error) { // Load server's certificate and private key serverCert, err := tls.LoadX509KeyPair(certFile, certKey) if err != nil { @@ -380,3 +365,34 @@ func streamInterceptor( wrapped.WrappedContext = context.WithValue(ctx, nbContext.RequestIDKey, reqID) return handler(srv, wrapped) } + +// realIPOptions builds the real-IP middleware options from the reverse proxy config. +// +// TrustedPeers controls which transport peers are allowed to supply forwarded-IP +// headers. If empty, forwarded headers are ignored and the transport peer address +// is used directly. Operators terminating connections at a reverse proxy should +// configure TrustedPeers with that proxy's address or network. +// +// Only X-Forwarded-For is trusted. X-Real-IP contains a single client-supplied +// address with no proxy chain to validate, and none of the reverse proxies we ship +// use it on the gRPC path. +func realIPOptions(cfg nbconfig.ReverseProxy) []realip.Option { + if idx := slices.IndexFunc(cfg.TrustedPeers, func(p netip.Prefix) bool { return p.Bits() == 0 }); idx >= 0 { + log.WithContext(context.Background()).Warnf("TrustedPeers contains the default route %s, which trusts "+ + "X-Forwarded-For from every client and allows connection IP spoofing. Set TrustedPeers to the address "+ + "of your reverse proxy, or leave it empty to use the connection's source address.", cfg.TrustedPeers[idx]) + } + if cfg.TrustedHTTPProxiesCount > 0 { + log.WithContext(context.Background()).Warn( + "TrustedHTTPProxiesCount skips X-Forwarded-For entries by position before TrustedHTTPProxies filters by address. " + + "An incorrect count may skip the real client IP and produce an incorrect source address.", + ) + } + + return []realip.Option{ + realip.WithTrustedPeers(cfg.TrustedPeers), + realip.WithTrustedProxies(cfg.TrustedHTTPProxies), + realip.WithTrustedProxiesCount(cfg.TrustedHTTPProxiesCount), + realip.WithHeaders([]string{realip.XForwardedFor}), + } +} diff --git a/management/internals/server/realip_test.go b/management/internals/server/realip_test.go new file mode 100644 index 000000000..89ac02730 --- /dev/null +++ b/management/internals/server/realip_test.go @@ -0,0 +1,171 @@ +package server + +import ( + "context" + "io" + "net" + "net/netip" + "testing" + "time" + + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/emptypb" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" +) + +const ( + realIPProbeMethod = "/netbird.test.RealIPProbe/Probe" + realIPProbeStreamMethod = "/netbird.test.RealIPProbe/ProbeStream" +) + +// realIPProbe records the real IP the middleware derived for each call. +type realIPProbe struct { + got chan string +} + +func (p *realIPProbe) record(ctx context.Context) { + addr, _ := realip.FromContext(ctx) + p.got <- addr.String() +} + +func (p *realIPProbe) wait(t *testing.T) string { + t.Helper() + + select { + case got := <-p.got: + return got + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for probe") + return "" + } +} + +func startProbeServer(t *testing.T, cfg nbconfig.ReverseProxy) (*grpc.ClientConn, *realIPProbe) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + probe := &realIPProbe{got: make(chan string, 1)} + opts := realIPOptions(cfg) + srv := grpc.NewServer( + grpc.ChainUnaryInterceptor(realip.UnaryServerInterceptorOpts(opts...)), + grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(opts...)), + ) + srv.RegisterService(&grpc.ServiceDesc{ + ServiceName: "netbird.test.RealIPProbe", + HandlerType: (*any)(nil), + Methods: []grpc.MethodDesc{{ + MethodName: "Probe", + Handler: func(_ any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + req := new(emptypb.Empty) + if err := dec(req); err != nil { + return nil, err + } + handler := func(ctx context.Context, _ any) (any, error) { + probe.record(ctx) + return &emptypb.Empty{}, nil + } + if interceptor == nil { + return handler(ctx, req) + } + return interceptor(ctx, req, &grpc.UnaryServerInfo{FullMethod: realIPProbeMethod}, handler) + }, + }}, + Streams: []grpc.StreamDesc{{ + StreamName: "ProbeStream", + ServerStreams: true, + Handler: func(_ any, stream grpc.ServerStream) error { + probe.record(stream.Context()) + return nil + }, + }}, + }, probe) + + go func() { _ = srv.Serve(listener) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return conn, probe +} + +func callUnary(t *testing.T, conn *grpc.ClientConn, probe *realIPProbe, kv ...string) string { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ctx = metadata.AppendToOutgoingContext(ctx, kv...) + require.NoError(t, conn.Invoke(ctx, realIPProbeMethod, &emptypb.Empty{}, &emptypb.Empty{})) + + return probe.wait(t) +} + +func callStream(t *testing.T, conn *grpc.ClientConn, probe *realIPProbe, kv ...string) string { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ctx = metadata.AppendToOutgoingContext(ctx, kv...) + desc := &grpc.StreamDesc{StreamName: "ProbeStream", ServerStreams: true} + stream, err := conn.NewStream(ctx, desc, realIPProbeStreamMethod) + require.NoError(t, err) + require.NoError(t, stream.CloseSend()) + require.ErrorIs(t, stream.RecvMsg(&emptypb.Empty{}), io.EOF) + + return probe.wait(t) +} + +func assertRealIP(t *testing.T, cfg nbconfig.ReverseProxy, want string, kv ...string) { + t.Helper() + + conn, probe := startProbeServer(t, cfg) + t.Run("unary", func(t *testing.T) { + assert.Equal(t, want, callUnary(t, conn, probe, kv...)) + }) + t.Run("stream", func(t *testing.T) { + assert.Equal(t, want, callStream(t, conn, probe, kv...)) + }) +} + +func TestRealIPDefaultIgnoresClientForwardedHeaders(t *testing.T) { + assertRealIP(t, nbconfig.ReverseProxy{}, "127.0.0.1", + realip.XForwardedFor, "203.0.113.44", + realip.XRealIp, "203.0.113.44", + ) +} + +func TestRealIPUntrustedPeerIgnoresForwardedHeaders(t *testing.T) { + cfg := nbconfig.ReverseProxy{TrustedPeers: []netip.Prefix{netip.MustParsePrefix("10.9.8.7/32")}} + + assertRealIP(t, cfg, "127.0.0.1", + realip.XForwardedFor, "203.0.113.44", + realip.XRealIp, "203.0.113.44", + ) +} + +func TestRealIPTrustedPeerHonoursForwardedHeaders(t *testing.T) { + cfg := nbconfig.ReverseProxy{TrustedPeers: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}} + + assertRealIP(t, cfg, "203.0.113.44", + realip.XForwardedFor, "203.0.113.44", + realip.XRealIp, "203.0.113.44", + ) +} + +func TestRealIPIgnoresXRealIPWhenProxyCountIsSet(t *testing.T) { + cfg := nbconfig.ReverseProxy{ + TrustedPeers: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}, + TrustedHTTPProxiesCount: 1, + } + + assertRealIP(t, cfg, "127.0.0.1", realip.XRealIp, "203.0.113.44") +} From 794956a7a313c8919fd92123fb06ff30b7b0032a Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 11 Sep 2026 16:21:10 +0200 Subject: [PATCH 35/63] [client] Fix relay instance address race (#7498) Read the relay instance URL and IP atomically to prevent reconnects from mixing values from different connections. Extend existing connection and offer/answer logs with relay URLs and IPs to help trace mismatched advertisements. --- client/internal/peer/handshaker.go | 8 +- shared/relay/client/client.go | 41 ++++---- shared/relay/client/client_serverip_test.go | 45 ++++----- shared/relay/client/manager.go | 6 +- shared/relay/client/manager_address_test.go | 103 ++++++++++++++++++++ shared/relay/client/picker.go | 7 +- 6 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 shared/relay/client/manager_address_test.go diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 6ecb2a947..654e32158 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -116,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) { for { select { case remoteOfferAnswer := <-h.remoteOffersCh: - h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) + h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP) // Record signaling received for reconnection attempts if h.metricsStages != nil { @@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) { continue } case remoteOfferAnswer := <-h.remoteAnswerCh: - h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) + h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP) // Record signaling received for reconnection attempts if h.metricsStages != nil { @@ -209,14 +209,14 @@ func (h *Handshaker) sendOffer() error { } offer := h.buildOfferAnswer() - h.log.Debugf("sending offer with serial: %s", offer.SessionIDString()) + h.log.Debugf("sending offer with serial: %s, relay server: %s, relay IP: %s", offer.SessionIDString(), offer.RelaySrvAddress, offer.RelaySrvIP) return h.signaler.SignalOffer(offer, h.config.Key) } func (h *Handshaker) sendAnswer() error { answer := h.buildOfferAnswer() - h.log.Debugf("sending answer with serial: %s", answer.SessionIDString()) + h.log.Debugf("sending answer with serial: %s, relay server: %s, relay IP: %s", answer.SessionIDString(), answer.RelaySrvAddress, answer.RelaySrvIP) return h.signaler.SignalAnswer(answer, h.config.Key) } diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go index 38c9c7375..7171b40ad 100644 --- a/shared/relay/client/client.go +++ b/shared/relay/client/client.go @@ -279,7 +279,7 @@ func (c *Client) Connect(ctx context.Context) error { c.stateSubscription = NewPeersStateSubscription(c.log, c.relayConn, c.closeConnsByPeerID) c.log = c.log.WithField("relay", instanceURL.String()) - c.log.Infof("relay connection established") + c.log.Infof("relay connection established, server IP: %s", connectedIP(c.relayConn)) c.serviceIsRunning = true @@ -364,23 +364,6 @@ func (c *Client) ServerInstanceURL() (string, error) { return c.instanceURL.String(), nil } -// ConnectedIP returns the IP address of the live relay-server connection, -// extracted from the underlying socket's RemoteAddr. Zero value if not -// connected or if the address is not an IP literal. -func (c *Client) ConnectedIP() netip.Addr { - c.mu.Lock() - conn := c.relayConn - c.mu.Unlock() - if conn == nil { - return netip.Addr{} - } - addr := conn.RemoteAddr() - if addr == nil { - return netip.Addr{} - } - return extractIPLiteral(addr.String()) -} - // SetOnDisconnectListener sets a function that will be called when the connection to the relay server is closed. func (c *Client) SetOnDisconnectListener(fn func(string)) { c.listenerMutex.Lock() @@ -777,6 +760,17 @@ func (c *Client) listenForStopEvents(ctx context.Context, hc *healthcheck.Receiv } } +func (c *Client) serverInstanceAddress() (string, netip.Addr, error) { + c.mu.Lock() + defer c.mu.Unlock() + + addr, err := c.ServerInstanceURL() + if err != nil { + return "", netip.Addr{}, err + } + return addr, connectedIP(c.relayConn), nil +} + func (c *Client) closeAllConns() { for _, container := range c.conns { container.close() @@ -923,6 +917,17 @@ func (c *Client) handlePeersWentOfflineMsg(buf []byte) { c.stateSubscription.OnPeersWentOffline(peersID) } +func connectedIP(conn net.Conn) netip.Addr { + if conn == nil { + return netip.Addr{} + } + addr := conn.RemoteAddr() + if addr == nil { + return netip.Addr{} + } + return extractIPLiteral(addr.String()) +} + // extractIPLiteral returns the IP from address forms produced by the relay // dialers (URL or host:port). Zero value if the host is not an IP. func extractIPLiteral(s string) netip.Addr { diff --git a/shared/relay/client/client_serverip_test.go b/shared/relay/client/client_serverip_test.go index 7e699e37d..a52d434f7 100644 --- a/shared/relay/client/client_serverip_test.go +++ b/shared/relay/client/client_serverip_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "github.com/netbirdio/netbird/client/iface" @@ -68,18 +70,17 @@ func TestClient_ServerIPRecoversFromUnresolvableFQDN(t *testing.T) { if !c.Ready() { t.Fatalf("client not ready after connect") } - if got := c.ConnectedIP(); got.String() != "127.0.0.1" { - t.Fatalf("ConnectedIP = %q, want 127.0.0.1", got) - } + url, ip, err := c.serverInstanceAddress() + require.NoError(t, err) + assert.Equal(t, srvCfg.ExposedAddress, url, "relay URL must come from the handshake") + assert.Equal(t, netip.MustParseAddr("127.0.0.1"), ip, "relay IP must come from the connection") }) } -// TestClient_ConnectedIPAfterFQDNDial verifies ConnectedIP returns the -// resolved IP after a successful FQDN-based dial. The underlying socket's -// RemoteAddr must be exposed through the dialer wrappers; if it returns -// the dial-time URL instead, ConnectedIP returns empty and the dial -// IP we advertise to peers is empty too. -func TestClient_ConnectedIPAfterFQDNDial(t *testing.T) { +// TestClient_ServerInstanceAddressAfterFQDNDial verifies the relay address +// includes the resolved IP after an FQDN dial. The dialer wrappers must expose +// the socket's RemoteAddr; returning the dial-time URL would lose the IP. +func TestClient_ServerInstanceAddressAfterFQDNDial(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -111,10 +112,10 @@ func TestClient_ConnectedIPAfterFQDNDial(t *testing.T) { } t.Cleanup(func() { _ = c.Close() }) - got := c.ConnectedIP().String() - if got != "127.0.0.1" && got != "::1" { - t.Fatalf("ConnectedIP after FQDN dial = %q, want 127.0.0.1 or ::1", got) - } + url, ip, err := c.serverInstanceAddress() + require.NoError(t, err) + assert.Equal(t, srvCfg.ExposedAddress, url, "relay URL must come from the handshake") + assert.Contains(t, []string{"127.0.0.1", "::1"}, ip.String(), "relay IP must resolve to localhost") } func TestSubstituteHost(t *testing.T) { @@ -214,15 +215,12 @@ func TestSubstituteHost(t *testing.T) { } } -func TestClient_ConnectedIPEmptyWhenNotConnected(t *testing.T) { - c := NewClient("rel://example.invalid:80", hmacTokenStore, "x", iface.DefaultMTU) - if got := c.ConnectedIP(); got.IsValid() { - t.Fatalf("ConnectedIP on disconnected client = %q, want zero", got) - } +func TestConnectedIPNilConnection(t *testing.T) { + assert.False(t, connectedIP(nil).IsValid(), "missing connection must not provide an IP") } // staticAddr is a net.Addr that returns a fixed string. Used to verify -// ConnectedIP parses RemoteAddr correctly. +// connectedIP parses RemoteAddr correctly. type staticAddr struct{ s string } func (a staticAddr) Network() string { return "tcp" } @@ -235,7 +233,7 @@ type stubConn struct { func (s stubConn) RemoteAddr() net.Addr { return s.remote } -func TestClient_ConnectedIPParsesRemoteAddr(t *testing.T) { +func TestConnectedIPParsesRemoteAddr(t *testing.T) { tests := []struct { name string s string @@ -252,15 +250,12 @@ func TestClient_ConnectedIPParsesRemoteAddr(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := &Client{relayConn: stubConn{remote: staticAddr{s: tt.s}}} - got := c.ConnectedIP() + got := connectedIP(stubConn{remote: staticAddr{s: tt.s}}) var gotStr string if got.IsValid() { gotStr = got.String() } - if gotStr != tt.want { - t.Errorf("ConnectedIP(%q) = %q, want %q", tt.s, gotStr, tt.want) - } + assert.Equal(t, tt.want, gotStr, "IP extracted from RemoteAddr %q", tt.s) }) } } diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index 50fcc0b8f..367c6dfc5 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -256,11 +256,7 @@ func (m *Manager) RelayInstanceAddress() (string, netip.Addr, error) { if m.relayClient == nil { return "", netip.Addr{}, ErrRelayClientNotConnected } - addr, err := m.relayClient.ServerInstanceURL() - if err != nil { - return "", netip.Addr{}, err - } - return addr, m.relayClient.ConnectedIP(), nil + return m.relayClient.serverInstanceAddress() } // ServerURLs returns the addresses of the relay servers. diff --git a/shared/relay/client/manager_address_test.go b/shared/relay/client/manager_address_test.go new file mode 100644 index 000000000..4f669e60d --- /dev/null +++ b/shared/relay/client/manager_address_test.go @@ -0,0 +1,103 @@ +package client + +import ( + "net/netip" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManager_RelayInstanceAddressAcrossReconnect(t *testing.T) { + relays := []struct { + url *RelayAddr + conn stubConn + ip netip.Addr + }{ + { + url: &RelayAddr{addr: "rels://relay-a.example:443"}, + conn: stubConn{remote: staticAddr{s: "192.0.2.1:443"}}, + ip: netip.MustParseAddr("192.0.2.1"), + }, + { + url: &RelayAddr{addr: "rels://relay-b.example:443"}, + conn: stubConn{remote: staticAddr{s: "192.0.2.2:443"}}, + ip: netip.MustParseAddr("192.0.2.2"), + }, + } + c := &Client{ + instanceURL: relays[0].url, + relayConn: relays[0].conn, + serviceIsRunning: true, + } + m := &Manager{relayClient: c} + started := make(chan struct{}) + stop := make(chan struct{}) + done := make(chan struct{}) + t.Cleanup(func() { + close(stop) + <-done + }) + go func() { + defer close(done) + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + // Publish successive connection states using the lifecycle locks. + // Yield before publication so a getter using only muInstanceURL + // can read the old URL while waiting for the new connection's IP. + c.mu.Lock() + runtime.Gosched() + relay := relays[i%len(relays)] + c.muInstanceURL.Lock() + c.instanceURL = relay.url + c.muInstanceURL.Unlock() + c.relayConn = relay.conn + c.mu.Unlock() + if i == 0 { + close(started) + } + } + }() + <-started + + for range 1000 { + url, ip, err := m.RelayInstanceAddress() + require.NoError(t, err) + wantIP := relays[0].ip + if url == relays[1].url.String() { + wantIP = relays[1].ip + } + if !assert.Equal(t, wantIP, ip, "advertised IP must belong to relay %s", url) { + return + } + } +} + +func TestManager_RelayInstanceAddressDisconnected(t *testing.T) { + for _, tt := range []struct { + name string + client *Client + }{ + {name: "no client"}, + {name: "not connected", client: &Client{}}, + { + name: "closed connection", + client: &Client{ + relayConn: stubConn{remote: staticAddr{s: "192.0.2.1:443"}}, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + m := &Manager{relayClient: tt.client} + url, ip, err := m.RelayInstanceAddress() + assert.Error(t, err) + assert.Empty(t, url, "disconnected relay must not advertise a URL") + assert.False(t, ip.IsValid(), "disconnected relay must not advertise a stale IP") + }) + } +} diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go index 17b1390b1..fc1d8c1cb 100644 --- a/shared/relay/client/picker.go +++ b/shared/relay/client/picker.go @@ -63,7 +63,12 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { if !ok { return nil, <-errChan } - log.Infof("chosen home Relay server: %s", cr.Url) + instanceURL, serverIP, err := cr.RelayClient.serverInstanceAddress() + if err != nil { + log.Infof("chosen home Relay server: %s, instance address unavailable: %v", cr.Url, err) + return cr.RelayClient, nil + } + log.Infof("chosen home Relay server: %s, instance URL: %s, server IP: %s", cr.Url, instanceURL, serverIP) return cr.RelayClient, nil case <-ctx.Done(): return nil, fmt.Errorf("connect to relay server: %w", ctx.Err()) From f422c4165437679a1470893e7917a8ef597a2277 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Fri, 11 Sep 2026 17:07:26 +0200 Subject: [PATCH 36/63] [management] extract peer update logic and wrap it in tests (#7338) * extract peer update loop into a dedicated struct and wrap it in tests Signed-off-by: Dmitri Dolguikh * make linter happy Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- encryption/message.go | 10 ++ .../shared/grpc/peer_update_handler.go | 135 +++++++++++++++ .../shared/grpc/peer_update_handler_test.go | 155 ++++++++++++++++++ management/internals/shared/grpc/server.go | 88 +--------- .../internals/shared/grpc/sync_sender_mock.go | 70 ++++++++ management/internals/shared/grpc/token_mgr.go | 2 + .../internals/shared/grpc/token_mgr_mock.go | 111 +++++++++++++ .../internals/shared/grpc/update_debouncer.go | 8 + .../shared/grpc/update_debouncer_mock.go | 96 +++++++++++ 9 files changed, 589 insertions(+), 86 deletions(-) create mode 100644 management/internals/shared/grpc/peer_update_handler.go create mode 100644 management/internals/shared/grpc/peer_update_handler_test.go create mode 100644 management/internals/shared/grpc/sync_sender_mock.go create mode 100644 management/internals/shared/grpc/token_mgr_mock.go create mode 100644 management/internals/shared/grpc/update_debouncer_mock.go diff --git a/encryption/message.go b/encryption/message.go index 6e4cd7391..2bf2c59dc 100644 --- a/encryption/message.go +++ b/encryption/message.go @@ -6,6 +6,16 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +type Encrypter interface { + EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) +} + +type DefaultEncrypter struct{} + +func (e DefaultEncrypter) EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) { + return EncryptMessage(remotePubKey, ourPrivateKey, message) +} + // EncryptMessage encrypts a body of the given protobuf Message func EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) { byteResp, err := pb.Marshal(message) diff --git a/management/internals/shared/grpc/peer_update_handler.go b/management/internals/shared/grpc/peer_update_handler.go new file mode 100644 index 000000000..d2f403841 --- /dev/null +++ b/management/internals/shared/grpc/peer_update_handler.go @@ -0,0 +1,135 @@ +package grpc + +import ( + "context" + "time" + + "github.com/netbirdio/netbird/encryption" + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/shared/management/proto" + log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func PeerUpdateHandlerFactory( + peerKey wgtypes.Key, + updates chan *network_map.UpdateMessage, + secretsManager SecretsManager, + srv proto.ManagementService_SyncServer, + cleanupfunc func()) *PeerUpdateHandler { + return &PeerUpdateHandler{ + peerKey: peerKey, + updates: updates, + secretsManager: secretsManager, + srv: srv, + encrypter: encryption.DefaultEncrypter{}, + debouncer: NewUpdateDebouncer(1000 * time.Millisecond), + cleanupFunc: cleanupfunc, + } +} + +// PeerUpdateHandler sends updates to the connected peer until the updates channel is closed. +// It implements a backpressure mechanism that sends the first update immediately, +// then debounces subsequent rapid updates, ensuring only the latest update is sent +// after a quiet period. +type PeerUpdateHandler struct { + peerKey wgtypes.Key + updates chan *network_map.UpdateMessage + appMetrics telemetry.AppMetrics + secretsManager SecretsManager + srv syncSender + encrypter encryption.Encrypter + debouncer Debouncer + cleanupFunc func() +} + +func (pu *PeerUpdateHandler) WithMetrics(appMetrics telemetry.AppMetrics) *PeerUpdateHandler { + pu.appMetrics = appMetrics + return pu +} + +//go:generate go tool mockgen -source=./peer_update_handler.go -destination=./sync_sender_mock.go -package=grpc +type syncSender interface { + Send(*proto.EncryptedMessage) error + Context() context.Context +} + +func (pu *PeerUpdateHandler) HandleUpdates(ctx context.Context) error { + log.WithContext(ctx).Tracef("starting to handle updates for peer %s", pu.peerKey.String()) + + defer pu.debouncer.Stop() + + for { + select { + // condition when there are some updates + // todo set the updates channel size to 1 + case update, open := <-pu.updates: + if pu.appMetrics != nil { + pu.appMetrics.GRPCMetrics().UpdateChannelQueueLength(len(pu.updates) + 1) + } + + if !open { + log.WithContext(ctx).Debugf("updates channel for peer %s was closed", pu.peerKey.String()) + pu.cleanupFunc() + return nil + } + + log.WithContext(ctx).Tracef("received an update for peer %s", pu.peerKey.String()) + if pu.debouncer.ProcessUpdate(update) { + // Send immediately (first update or after quiet period) + if err := pu.SendUpdate(ctx, update); err != nil { + log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", pu.peerKey.String(), err) + return err + } + } + + // Timer expired - quiet period reached, send pending updates if any + case <-pu.debouncer.TimerChannel(): + pendingUpdates := pu.debouncer.GetPendingUpdates() + if len(pendingUpdates) == 0 { + continue + } + log.WithContext(ctx).Debugf("sending %d debounced update(s) for peer %s", len(pendingUpdates), pu.peerKey.String()) + for _, pendingUpdate := range pendingUpdates { + if err := pu.SendUpdate(ctx, pendingUpdate); err != nil { + log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", pu.peerKey.String(), err) + return err + } + } + + // condition when client <-> server connection has been terminated + case <-pu.srv.Context().Done(): + // happens when connection drops, e.g. client disconnects + log.WithContext(ctx).Debugf("stream of peer %s has been closed", pu.peerKey.String()) + pu.cleanupFunc() + return pu.srv.Context().Err() + } + } +} + +func (pu *PeerUpdateHandler) SendUpdate(ctx context.Context, update *network_map.UpdateMessage) error { + key, err := pu.secretsManager.GetWGKey() + if err != nil { + pu.cleanupFunc() + return status.Errorf(codes.Internal, "failed processing update message") + } + + encryptedResp, err := pu.encrypter.EncryptMessage(pu.peerKey, key, update.Update) + if err != nil { + pu.cleanupFunc() + return status.Errorf(codes.Internal, "failed processing update message") + } + err = pu.srv.Send(&proto.EncryptedMessage{ + WgPubKey: key.PublicKey().String(), + Body: encryptedResp, + }) + if err != nil { + pu.cleanupFunc() + return status.Errorf(codes.Internal, "failed sending update message") + } + log.WithContext(ctx).Tracef("sent an update to peer %s", pu.peerKey.String()) + return nil +} diff --git a/management/internals/shared/grpc/peer_update_handler_test.go b/management/internals/shared/grpc/peer_update_handler_test.go new file mode 100644 index 000000000..02de49c47 --- /dev/null +++ b/management/internals/shared/grpc/peer_update_handler_test.go @@ -0,0 +1,155 @@ +package grpc + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + pb "github.com/golang/protobuf/proto" //nolint + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func TestSendPeerUpdates_FirstUpdate(t *testing.T) { + ctrl := gomock.NewController(t) + secretsManager := NewMockSecretsManager(ctrl) + updateDebouncer := NewMockDebouncer(ctrl) + syncSender := NewMocksyncSender(ctrl) + + pu := PeerUpdateHandler{ + peerKey: mustGenerateKey(t), + updates: make(chan *network_map.UpdateMessage), + secretsManager: secretsManager, + encrypter: testEncrypter{}, + debouncer: updateDebouncer, + srv: syncSender, + cleanupFunc: func() {}, + } + + msg := network_map.UpdateMessage{ + Update: &proto.SyncResponse{Version: 1}, + } + + timeCh := make(chan time.Time) + srvCtx := context.TODO() + srvKey := mustGenerateKey(t) + // mock a first update, should send it right away + updateDebouncer.EXPECT().ProcessUpdate(gomock.Eq(&msg)).Return(true) + updateDebouncer.EXPECT().TimerChannel().AnyTimes().Return(timeCh) + syncSender.EXPECT().Context().AnyTimes().Return(srvCtx) + secretsManager.EXPECT().GetWGKey().Return(srvKey, nil) + syncSender.EXPECT().Send(pbMatcher{x: &proto.EncryptedMessage{WgPubKey: srvKey.PublicKey().String(), Body: mustMarshal(t, &msg)}}) + updateDebouncer.EXPECT().Stop() + + var wg sync.WaitGroup + wg.Go(func() { pu.HandleUpdates(context.TODO()) }) //nolint:errcheck + pu.updates <- &msg + close(pu.updates) + wg.Wait() +} + +func TestSendPeerUpdates_TimerUpdate(t *testing.T) { + ctrl := gomock.NewController(t) + secretsManager := NewMockSecretsManager(ctrl) + updateDebouncer := NewMockDebouncer(ctrl) + syncSender := NewMocksyncSender(ctrl) + + pu := PeerUpdateHandler{ + peerKey: mustGenerateKey(t), + updates: make(chan *network_map.UpdateMessage), + secretsManager: secretsManager, + encrypter: testEncrypter{}, + debouncer: updateDebouncer, + srv: syncSender, + cleanupFunc: func() {}, + } + + msg := network_map.UpdateMessage{ + Update: &proto.SyncResponse{Version: 1}, + } + + timeCh := make(chan time.Time) + srvCtx := context.TODO() + srvKey := mustGenerateKey(t) + updateDebouncer.EXPECT().GetPendingUpdates().Return([]*network_map.UpdateMessage{&msg}) + updateDebouncer.EXPECT().TimerChannel().AnyTimes().Return(timeCh) + syncSender.EXPECT().Context().AnyTimes().Return(srvCtx) + secretsManager.EXPECT().GetWGKey().Return(srvKey, nil) + syncSender.EXPECT().Send(pbMatcher{x: &proto.EncryptedMessage{WgPubKey: srvKey.PublicKey().String(), Body: mustMarshal(t, &msg)}}) + updateDebouncer.EXPECT().Stop() + + var wg sync.WaitGroup + wg.Go(func() { pu.HandleUpdates(context.TODO()) }) //nolint:errcheck + timeCh <- time.Now() + close(pu.updates) + wg.Wait() +} + +func TestSendPeerUpdates_ServerContextDone(t *testing.T) { + ctrl := gomock.NewController(t) + secretsManager := NewMockSecretsManager(ctrl) + updateDebouncer := NewMockDebouncer(ctrl) + syncSender := NewMocksyncSender(ctrl) + + pu := PeerUpdateHandler{ + peerKey: mustGenerateKey(t), + updates: make(chan *network_map.UpdateMessage), + secretsManager: secretsManager, + encrypter: testEncrypter{}, + debouncer: updateDebouncer, + srv: syncSender, + cleanupFunc: func() {}, + } + + timeCh := make(chan time.Time) + srvCtx, cancel := context.WithCancel(context.TODO()) + updateDebouncer.EXPECT().TimerChannel().AnyTimes().Return(timeCh) + syncSender.EXPECT().Context().AnyTimes().Return(srvCtx) + updateDebouncer.EXPECT().Stop() + + var wg sync.WaitGroup + wg.Go(func() { pu.HandleUpdates(context.TODO()) }) //nolint:errcheck + cancel() + wg.Wait() +} + +func mustGenerateKey(t *testing.T) wgtypes.Key { + t.Helper() + k, err := wgtypes.GenerateKey() + assert.NoError(t, err) + return k +} + +func mustMarshal(t *testing.T, msg *network_map.UpdateMessage) []byte { + t.Helper() + r, err := pb.Marshal(msg.Update) + assert.NoError(t, err) + return r +} + +type testEncrypter struct{} + +func (testEncrypter) EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) { + return pb.Marshal(message) +} + +type pbMatcher struct { + x pb.Message +} + +func (pbm pbMatcher) Matches(x any) bool { + msg, ok := x.(pb.Message) + if !ok { + return false + } + return pb.Equal(pbm.x, msg) +} + +func (pbm pbMatcher) String() string { + return fmt.Sprintf("is equal to %s (%T)", pbm.x, pbm.x) +} diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index a9cc0ad36..c178b6fa1 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -337,7 +337,8 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S s.syncSem.Add(-1) - return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv, syncStart) + return PeerUpdateHandlerFactory(peerKey, updates, s.secretsManager, srv, func() { s.cancelPeerRoutines(ctx, accountID, peer, syncStart) }). + WithMetrics(s.appMetrics).HandleUpdates(ctx) } func (s *Server) handleHandshake(ctx context.Context, srv proto.ManagementService_JobServer) (wgtypes.Key, error) { @@ -404,91 +405,6 @@ func (s *Server) sendJobsLoop(ctx context.Context, accountID string, peerKey wgt } } -// handleUpdates sends updates to the connected peer until the updates channel is closed. -// It implements a backpressure mechanism that sends the first update immediately, -// then debounces subsequent rapid updates, ensuring only the latest update is sent -// after a quiet period. -func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error { - log.WithContext(ctx).Tracef("starting to handle updates for peer %s", peerKey.String()) - - // Create a debouncer for this peer connection - debouncer := NewUpdateDebouncer(1000 * time.Millisecond) - defer debouncer.Stop() - - for { - select { - // condition when there are some updates - // todo set the updates channel size to 1 - case update, open := <-updates: - if s.appMetrics != nil { - s.appMetrics.GRPCMetrics().UpdateChannelQueueLength(len(updates) + 1) - } - - if !open { - log.WithContext(ctx).Debugf("updates channel for peer %s was closed", peerKey.String()) - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return nil - } - - log.WithContext(ctx).Tracef("received an update for peer %s", peerKey.String()) - if debouncer.ProcessUpdate(update) { - // Send immediately (first update or after quiet period) - if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv, streamStartTime); err != nil { - log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) - return err - } - } - - // Timer expired - quiet period reached, send pending updates if any - case <-debouncer.TimerChannel(): - pendingUpdates := debouncer.GetPendingUpdates() - if len(pendingUpdates) == 0 { - continue - } - log.WithContext(ctx).Debugf("sending %d debounced update(s) for peer %s", len(pendingUpdates), peerKey.String()) - for _, pendingUpdate := range pendingUpdates { - if err := s.sendUpdate(ctx, accountID, peerKey, peer, pendingUpdate, srv, streamStartTime); err != nil { - log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) - return err - } - } - - // condition when client <-> server connection has been terminated - case <-srv.Context().Done(): - // happens when connection drops, e.g. client disconnects - log.WithContext(ctx).Debugf("stream of peer %s has been closed", peerKey.String()) - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return srv.Context().Err() - } - } -} - -// sendUpdate encrypts the update message using the peer key and the server's wireguard key, -// then sends the encrypted message to the connected peer via the sync server. -func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, update *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error { - key, err := s.secretsManager.GetWGKey() - if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return status.Errorf(codes.Internal, "failed processing update message") - } - - encryptedResp, err := encryption.EncryptMessage(peerKey, key, update.Update) - if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return status.Errorf(codes.Internal, "failed processing update message") - } - err = srv.Send(&proto.EncryptedMessage{ - WgPubKey: key.PublicKey().String(), - Body: encryptedResp, - }) - if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return status.Errorf(codes.Internal, "failed sending update message") - } - log.WithContext(ctx).Tracef("sent an update to peer %s", peerKey.String()) - return nil -} - // sendJob encrypts the update message using the peer key and the server's wireguard key, // then sends the encrypted message to the connected peer via the sync server. func (s *Server) sendJob(ctx context.Context, peerKey wgtypes.Key, job *job.Event, srv proto.ManagementService_JobServer) error { diff --git a/management/internals/shared/grpc/sync_sender_mock.go b/management/internals/shared/grpc/sync_sender_mock.go new file mode 100644 index 000000000..3d1696f59 --- /dev/null +++ b/management/internals/shared/grpc/sync_sender_mock.go @@ -0,0 +1,70 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./peer_update_handler.go +// +// Generated by this command: +// +// mockgen -source=./peer_update_handler.go -destination=./sync_sender_mock.go -package=grpc +// + +// Package grpc is a generated GoMock package. +package grpc + +import ( + context "context" + reflect "reflect" + + proto "github.com/netbirdio/netbird/shared/management/proto" + gomock "go.uber.org/mock/gomock" +) + +// MocksyncSender is a mock of syncSender interface. +type MocksyncSender struct { + ctrl *gomock.Controller + recorder *MocksyncSenderMockRecorder + isgomock struct{} +} + +// MocksyncSenderMockRecorder is the mock recorder for MocksyncSender. +type MocksyncSenderMockRecorder struct { + mock *MocksyncSender +} + +// NewMocksyncSender creates a new mock instance. +func NewMocksyncSender(ctrl *gomock.Controller) *MocksyncSender { + mock := &MocksyncSender{ctrl: ctrl} + mock.recorder = &MocksyncSenderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MocksyncSender) EXPECT() *MocksyncSenderMockRecorder { + return m.recorder +} + +// Context mocks base method. +func (m *MocksyncSender) Context() context.Context { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Context") + ret0, _ := ret[0].(context.Context) + return ret0 +} + +// Context indicates an expected call of Context. +func (mr *MocksyncSenderMockRecorder) Context() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MocksyncSender)(nil).Context)) +} + +// Send mocks base method. +func (m *MocksyncSender) Send(arg0 *proto.EncryptedMessage) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Send", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Send indicates an expected call of Send. +func (mr *MocksyncSenderMockRecorder) Send(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MocksyncSender)(nil).Send), arg0) +} diff --git a/management/internals/shared/grpc/token_mgr.go b/management/internals/shared/grpc/token_mgr.go index fb2d83a9a..4dab5007f 100644 --- a/management/internals/shared/grpc/token_mgr.go +++ b/management/internals/shared/grpc/token_mgr.go @@ -25,6 +25,8 @@ import ( const defaultDuration = 12 * time.Hour // SecretsManager used to manage TURN and relay secrets +// +//go:generate go tool mockgen -source=./token_mgr.go -destination=./token_mgr_mock.go -package=grpc type SecretsManager interface { GenerateTurnToken() (*Token, error) GenerateRelayToken() (*Token, error) diff --git a/management/internals/shared/grpc/token_mgr_mock.go b/management/internals/shared/grpc/token_mgr_mock.go new file mode 100644 index 000000000..e7dde4e3a --- /dev/null +++ b/management/internals/shared/grpc/token_mgr_mock.go @@ -0,0 +1,111 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./token_mgr.go +// +// Generated by this command: +// +// mockgen -source=./token_mgr.go -destination=./token_mgr_mock.go -package=grpc +// + +// Package grpc is a generated GoMock package. +package grpc + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + wgtypes "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// MockSecretsManager is a mock of SecretsManager interface. +type MockSecretsManager struct { + ctrl *gomock.Controller + recorder *MockSecretsManagerMockRecorder + isgomock struct{} +} + +// MockSecretsManagerMockRecorder is the mock recorder for MockSecretsManager. +type MockSecretsManagerMockRecorder struct { + mock *MockSecretsManager +} + +// NewMockSecretsManager creates a new mock instance. +func NewMockSecretsManager(ctrl *gomock.Controller) *MockSecretsManager { + mock := &MockSecretsManager{ctrl: ctrl} + mock.recorder = &MockSecretsManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSecretsManager) EXPECT() *MockSecretsManagerMockRecorder { + return m.recorder +} + +// CancelRefresh mocks base method. +func (m *MockSecretsManager) CancelRefresh(peerKey string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "CancelRefresh", peerKey) +} + +// CancelRefresh indicates an expected call of CancelRefresh. +func (mr *MockSecretsManagerMockRecorder) CancelRefresh(peerKey any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelRefresh", reflect.TypeOf((*MockSecretsManager)(nil).CancelRefresh), peerKey) +} + +// GenerateRelayToken mocks base method. +func (m *MockSecretsManager) GenerateRelayToken() (*Token, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GenerateRelayToken") + ret0, _ := ret[0].(*Token) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GenerateRelayToken indicates an expected call of GenerateRelayToken. +func (mr *MockSecretsManagerMockRecorder) GenerateRelayToken() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateRelayToken", reflect.TypeOf((*MockSecretsManager)(nil).GenerateRelayToken)) +} + +// GenerateTurnToken mocks base method. +func (m *MockSecretsManager) GenerateTurnToken() (*Token, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GenerateTurnToken") + ret0, _ := ret[0].(*Token) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GenerateTurnToken indicates an expected call of GenerateTurnToken. +func (mr *MockSecretsManagerMockRecorder) GenerateTurnToken() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateTurnToken", reflect.TypeOf((*MockSecretsManager)(nil).GenerateTurnToken)) +} + +// GetWGKey mocks base method. +func (m *MockSecretsManager) GetWGKey() (wgtypes.Key, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWGKey") + ret0, _ := ret[0].(wgtypes.Key) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWGKey indicates an expected call of GetWGKey. +func (mr *MockSecretsManagerMockRecorder) GetWGKey() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWGKey", reflect.TypeOf((*MockSecretsManager)(nil).GetWGKey)) +} + +// SetupRefresh mocks base method. +func (m *MockSecretsManager) SetupRefresh(ctx context.Context, accountID, peerKey string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetupRefresh", ctx, accountID, peerKey) +} + +// SetupRefresh indicates an expected call of SetupRefresh. +func (mr *MockSecretsManagerMockRecorder) SetupRefresh(ctx, accountID, peerKey any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetupRefresh", reflect.TypeOf((*MockSecretsManager)(nil).SetupRefresh), ctx, accountID, peerKey) +} diff --git a/management/internals/shared/grpc/update_debouncer.go b/management/internals/shared/grpc/update_debouncer.go index 8af9c2656..9483007c6 100644 --- a/management/internals/shared/grpc/update_debouncer.go +++ b/management/internals/shared/grpc/update_debouncer.go @@ -6,6 +6,14 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map" ) +//go:generate go tool mockgen -source=./update_debouncer.go -destination=./update_debouncer_mock.go -package=grpc +type Debouncer interface { + Stop() + TimerChannel() <-chan time.Time + ProcessUpdate(update *network_map.UpdateMessage) bool + GetPendingUpdates() []*network_map.UpdateMessage +} + // UpdateDebouncer implements a backpressure mechanism that: // - Sends the first update immediately // - Coalesces rapid subsequent network map updates (only latest matters) diff --git a/management/internals/shared/grpc/update_debouncer_mock.go b/management/internals/shared/grpc/update_debouncer_mock.go new file mode 100644 index 000000000..4dff632b7 --- /dev/null +++ b/management/internals/shared/grpc/update_debouncer_mock.go @@ -0,0 +1,96 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./update_debouncer.go +// +// Generated by this command: +// +// mockgen -source=./update_debouncer.go -destination=./update_debouncer_mock.go -package=grpc +// + +// Package grpc is a generated GoMock package. +package grpc + +import ( + reflect "reflect" + time "time" + + network_map "github.com/netbirdio/netbird/management/internals/controllers/network_map" + gomock "go.uber.org/mock/gomock" +) + +// MockDebouncer is a mock of Debouncer interface. +type MockDebouncer struct { + ctrl *gomock.Controller + recorder *MockDebouncerMockRecorder + isgomock struct{} +} + +// MockDebouncerMockRecorder is the mock recorder for MockDebouncer. +type MockDebouncerMockRecorder struct { + mock *MockDebouncer +} + +// NewMockDebouncer creates a new mock instance. +func NewMockDebouncer(ctrl *gomock.Controller) *MockDebouncer { + mock := &MockDebouncer{ctrl: ctrl} + mock.recorder = &MockDebouncerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDebouncer) EXPECT() *MockDebouncerMockRecorder { + return m.recorder +} + +// GetPendingUpdates mocks base method. +func (m *MockDebouncer) GetPendingUpdates() []*network_map.UpdateMessage { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPendingUpdates") + ret0, _ := ret[0].([]*network_map.UpdateMessage) + return ret0 +} + +// GetPendingUpdates indicates an expected call of GetPendingUpdates. +func (mr *MockDebouncerMockRecorder) GetPendingUpdates() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPendingUpdates", reflect.TypeOf((*MockDebouncer)(nil).GetPendingUpdates)) +} + +// ProcessUpdate mocks base method. +func (m *MockDebouncer) ProcessUpdate(update *network_map.UpdateMessage) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ProcessUpdate", update) + ret0, _ := ret[0].(bool) + return ret0 +} + +// ProcessUpdate indicates an expected call of ProcessUpdate. +func (mr *MockDebouncerMockRecorder) ProcessUpdate(update any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProcessUpdate", reflect.TypeOf((*MockDebouncer)(nil).ProcessUpdate), update) +} + +// Stop mocks base method. +func (m *MockDebouncer) Stop() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Stop") +} + +// Stop indicates an expected call of Stop. +func (mr *MockDebouncerMockRecorder) Stop() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockDebouncer)(nil).Stop)) +} + +// TimerChannel mocks base method. +func (m *MockDebouncer) TimerChannel() <-chan time.Time { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TimerChannel") + ret0, _ := ret[0].(<-chan time.Time) + return ret0 +} + +// TimerChannel indicates an expected call of TimerChannel. +func (mr *MockDebouncerMockRecorder) TimerChannel() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TimerChannel", reflect.TypeOf((*MockDebouncer)(nil).TimerChannel)) +} From ec0c36b0e7b34fd17948ff3fcd7c77d50104750e Mon Sep 17 00:00:00 2001 From: Brandon Hopkins <76761586+TechHutTV@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:25:10 -0700 Subject: [PATCH 37/63] [client] Add light mode with system, light, and dark theme options (#7344) * desktop UI light mode * Theme review fixes plus macOS window outline fix * Windows runtime chrome re-theming plus apply serialization * Windows chrome threading and theme event ordering fixes * Darken toggle and setting sidebar text * resolve theme appearance, apply on UI thread * read theme once per window * Re-assert Windows dark opt-in after SetTheme * split app-wide GTK theming from per-window chrome * Update Wails dependency and checksums * KDE tray icon panel fix * Five review fixes: theme ordering, cgo dedup, KDE panel resolution * Path guard hardening, toggle contrast, windows comment * non-vacuous escape tests * Default view edits * Polish settings nav, controls, borders, and disc * Profiles settings boarder, modals, and buttons * Additional edits based on feedback * Switch colors away from slight blue hue * Update missing lang * Fix vertical tab active view --- client/ui/frontend/index.html | 18 + client/ui/frontend/src/app.tsx | 57 +-- .../src/assets/logos/netbird-full-light.svg | 19 + client/ui/frontend/src/components/Badge.tsx | 12 +- .../src/components/CopyToClipboard.tsx | 2 +- .../frontend/src/components/DropdownMenu.tsx | 2 +- .../src/components/LanguagePicker.tsx | 6 +- .../ui/frontend/src/components/SquareIcon.tsx | 4 +- .../frontend/src/components/ThemePicker.tsx | 109 ++++++ client/ui/frontend/src/components/Tooltip.tsx | 4 +- .../frontend/src/components/VerticalTabs.tsx | 14 +- .../src/components/buttons/Button.tsx | 49 +-- .../src/components/buttons/IconButton.tsx | 2 +- .../frontend/src/components/dialog/Dialog.tsx | 4 +- .../frontend/src/components/inputs/Input.tsx | 18 +- .../src/components/inputs/SearchInput.tsx | 2 +- .../components/switches/FancyToggleSwitch.tsx | 6 +- .../src/components/switches/SwitchItem.tsx | 6 +- .../components/switches/SwitchItemGroup.tsx | 2 +- .../src/components/switches/ToggleSwitch.tsx | 6 +- .../src/components/typography/HelpText.tsx | 2 +- .../ui/frontend/src/contexts/ThemeContext.tsx | 129 +++++++ client/ui/frontend/src/globals.css | 65 +++- .../ui/frontend/src/layouts/AppRightPanel.tsx | 2 +- client/ui/frontend/src/lib/formatters.ts | 4 +- .../main/MainConnectionStatusSwitch.tsx | 13 +- .../src/modules/main/MainExitNodeSwitcher.tsx | 6 +- .../frontend/src/modules/main/MainHeader.tsx | 4 +- .../src/modules/main/advanced/Navigation.tsx | 2 +- .../main/advanced/networks/NetworkFilters.tsx | 2 +- .../main/advanced/networks/Networks.tsx | 9 +- .../main/advanced/peers/PeerDetailPanel.tsx | 12 +- .../main/advanced/peers/PeerFilters.tsx | 2 +- .../src/modules/main/advanced/peers/Peers.tsx | 6 +- .../src/modules/profiles/ProfileDropdown.tsx | 10 +- .../src/modules/profiles/ProfilesTab.tsx | 8 +- .../src/modules/settings/SettingsAbout.tsx | 16 +- .../src/modules/settings/SettingsGeneral.tsx | 2 + .../src/modules/settings/SettingsSection.tsx | 4 +- .../settings/SettingsTroubleshooting.tsx | 13 +- client/ui/frontend/tailwind.config.ts | 47 +-- client/ui/i18n/locales/de/common.json | 15 + client/ui/i18n/locales/en/common.json | 20 + client/ui/i18n/locales/es/common.json | 15 + client/ui/i18n/locales/fr/common.json | 15 + client/ui/i18n/locales/hu/common.json | 15 + client/ui/i18n/locales/it/common.json | 15 + client/ui/i18n/locales/ja/common.json | 15 + client/ui/i18n/locales/pt/common.json | 15 + client/ui/i18n/locales/ru/common.json | 15 + client/ui/i18n/locales/uk/common.json | 19 +- client/ui/i18n/locales/zh-CN/common.json | 15 + client/ui/main.go | 11 +- client/ui/preferences/store.go | 53 ++- client/ui/services/appappearance_linux.go | 99 +++++ client/ui/services/appappearance_linux_gtk.go | 63 ++++ client/ui/services/appappearance_other.go | 7 + client/ui/services/preferences.go | 4 + client/ui/services/theme.go | 194 ++++++++++ client/ui/services/windowappearance_darwin.go | 53 +++ client/ui/services/windowappearance_other.go | 16 + .../ui/services/windowappearance_windows.go | 54 +++ client/ui/services/windowmanager.go | 123 +++++- client/ui/tray_theme_linux.go | 147 ++++++-- client/ui/tray_theme_linux_test.go | 357 ++++++++++++++++-- client/ui/tray_theme_watcher_linux.go | 51 +-- go.mod | 2 +- go.sum | 4 +- 68 files changed, 1853 insertions(+), 259 deletions(-) create mode 100644 client/ui/frontend/src/assets/logos/netbird-full-light.svg create mode 100644 client/ui/frontend/src/components/ThemePicker.tsx create mode 100644 client/ui/frontend/src/contexts/ThemeContext.tsx create mode 100644 client/ui/services/appappearance_linux.go create mode 100644 client/ui/services/appappearance_linux_gtk.go create mode 100644 client/ui/services/appappearance_other.go create mode 100644 client/ui/services/theme.go create mode 100644 client/ui/services/windowappearance_darwin.go create mode 100644 client/ui/services/windowappearance_other.go create mode 100644 client/ui/services/windowappearance_windows.go diff --git a/client/ui/frontend/index.html b/client/ui/frontend/index.html index e62139956..f4c8b3d68 100644 --- a/client/ui/frontend/index.html +++ b/client/ui/frontend/index.html @@ -6,7 +6,25 @@ NetBird +
diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 7f1359510..6accda36f 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -13,6 +13,7 @@ import { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { welcome } from "@/lib/welcome"; import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx"; +import { ThemeProvider } from "@/contexts/ThemeContext.tsx"; import { initI18n } from "@/lib/i18n"; import { initPlatform } from "@/lib/platform"; import { initLogForwarding } from "@/lib/logs"; @@ -35,30 +36,38 @@ Promise.all([ ]).finally(() => { ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - } - /> - } /> - } - /> - } /> - } /> - - }> - } /> - } /> - } /> - - - - + + + + + + } + /> + } + /> + } + /> + } /> + } /> + + }> + } /> + } /> + } /> + + + + + , ); }); diff --git a/client/ui/frontend/src/assets/logos/netbird-full-light.svg b/client/ui/frontend/src/assets/logos/netbird-full-light.svg new file mode 100644 index 000000000..3457b50c6 --- /dev/null +++ b/client/ui/frontend/src/assets/logos/netbird-full-light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx index c5e2b5f22..a6f2e886a 100644 --- a/client/ui/frontend/src/components/Badge.tsx +++ b/client/ui/frontend/src/components/Badge.tsx @@ -11,12 +11,14 @@ type Props = HTMLAttributes & { }; const VARIANT_CLASSES: Record = { - info: "bg-sky-900 border border-sky-700 text-sky-200", + info: "bg-sky-100 border border-sky-300 text-sky-800 dark:bg-sky-900 dark:border-sky-700 dark:text-sky-200", neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200", - brand: "bg-netbird/15 border border-netbird/30 text-netbird", - success: "bg-green-900 border border-green-700 text-green-200", - warning: "bg-yellow-900 border border-yellow-700 text-yellow-200", - danger: "bg-red-900 border border-red-700 text-red-200", + brand: "bg-netbird/15 border border-netbird/30 text-netbird-700 dark:text-netbird", + success: + "bg-green-100 border border-green-300 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-200", + warning: + "bg-yellow-100 border border-yellow-300 text-yellow-800 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200", + danger: "bg-red-100 border border-red-300 text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200", }; export const Badge = forwardRef(function Badge( diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 3cf681a1c..4af4ecc8f 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -81,7 +81,7 @@ export const CopyToClipboard = ({ aria-live={"polite"} className={cn( "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", className, )} > diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx index d43c37e1b..8cedcea03 100644 --- a/client/ui/frontend/src/components/DropdownMenu.tsx +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -16,7 +16,7 @@ const menuItemVariants = cva("", { variant: { default: "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50", - danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500", + danger: "text-red-500 hover:bg-red-500/10 hover:text-red-500 focus-visible:bg-red-500/10 focus-visible:text-red-500 dark:hover:bg-red-900/20 dark:focus-visible:bg-red-900/20", }, }, defaultVariants: { variant: "default" }, diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index 7a30f8b33..35ef7d5b5 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -97,9 +97,9 @@ export function LanguagePicker() { "rounded-md border bg-white dark:bg-nb-gray-900", "border-neutral-200 dark:border-nb-gray-700", "cursor-default text-xs font-semibold text-nb-gray-100 outline-none", - "hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600", + "hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "disabled:opacity-50", )} > @@ -157,7 +157,7 @@ export function LanguagePicker() { placeholder={t("settings.general.language.search")} aria-label={t("settings.general.language.search")} className={cn( - "w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300", + "w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-300", "border-none outline-none", )} /> diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx index e904d2de5..aaf3b1100 100644 --- a/client/ui/frontend/src/components/SquareIcon.tsx +++ b/client/ui/frontend/src/components/SquareIcon.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/cn"; export type SquareIconVariant = "default" | "info" | "warning" | "danger"; const variantClass: Record = { - default: "text-white", + default: "text-nb-gray-50", info: "text-sky-400", warning: "text-netbird", danger: "text-red-500", @@ -27,7 +27,7 @@ export const SquareIcon = ({
o.value === theme) ?? OPTIONS[0]; + const CurrentIcon = current.icon; + + const select = async (value: string) => { + if (busy || value === theme) return; + setBusy(true); + try { + await setTheme(value as ThemePreference); + } catch (e) { + await errorDialog({ + Title: t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + {t("settings.general.theme.help")} +
+
+ + + + + + void select(v)}> + {OPTIONS.map(({ value, icon: Icon, labelKey }) => ( + + + {t(labelKey)} + + ))} + + + +
+
+ ); +} diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index 2c77ba139..d7a85277a 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -81,12 +81,12 @@ export const Tooltip = ({ onPointerLeave={interactive ? scheduleClose : undefined} onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()} className={cn( - "z-50 select-none text-xs text-nb-gray-100 shadow-lg", + "z-50 select-none text-xs text-nb-gray-100 shadow-sm dark:shadow-lg", "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", !interactive && "pointer-events-none", contentClassName ?? - "rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", + "rounded-md border border-nb-gray-800 bg-white px-2 py-1 dark:border-nb-gray-850 dark:bg-nb-gray-900", )} > {content} diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index 1aedf82a6..306850ee2 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -46,12 +46,12 @@ const Trigger = forwardRef(function VerticalTab (function VerticalTab aria-hidden={"true"} className={cn( "ml-2 shrink-0 transition-colors duration-150", - "text-nb-gray-400 group-data-[state=active]:text-nb-gray-100", + "text-nb-gray-350 dark:text-nb-gray-400", + "group-data-[state=active]:text-nb-gray-100", )} /> {title} diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx index 6b151c17b..931988ead 100644 --- a/client/ui/frontend/src/components/buttons/Button.tsx +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -24,71 +24,74 @@ const buttonVariants = cva( variants: { variant: { default: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", - "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", + "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-nb-gray-50 dark:focus:ring-zinc-800/50", ], primary: [ - "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900", - "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50", + "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-nb-gray-50 disabled:dark:bg-nb-gray-900", + "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50 disabled:bg-nb-gray-700", ], secondary: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", - "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", - "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white", + "border-neutral-200 bg-white text-neutral-900 hover:border-nb-gray-700 hover:bg-nb-gray-950 hover:text-black focus:ring-nb-gray-500/50 focus:ring-offset-0", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 dark:focus:ring-offset-1", + "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:border-gray-700/40 dark:hover:bg-nb-gray-910 dark:hover:text-nb-gray-50", ], secondaryLighter: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", - "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white", + "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-nb-gray-50", ], subtle: [ - "border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60", + "border-neutral-200 bg-neutral-50 text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-200/60", "dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40", "dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950", ], input: [ - "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", "dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80", ], dropdown: [ - "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", "dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50", ], dotted: [ - "border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-dashed border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", - "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white", + "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-nb-gray-50", ], tertiary: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", ], white: [ - "border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "border-white bg-white text-neutral-800 outline-none hover:bg-neutral-200 focus:ring-white/50 dark:text-gray-800 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", "disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300", ], outline: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50", ], "danger-outline": [ + "bg-transparent text-red-600 enabled:hover:bg-red-50 enabled:focus:ring-red-200/50", "dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20", ], "danger-text": [ - "rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", + "rounded-sm border-transparent bg-transparent !px-0 !py-0 text-red-600 !shadow-none hover:text-red-700 focus:ring-red-500/30", + "dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", ], "default-outline": [ - "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", - "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", - "data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white", + "ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20", + "border-transparent bg-transparent text-nb-gray-400 hover:border-nb-gray-800/50 hover:bg-nb-gray-900/30 hover:text-nb-gray-50", + "data-[state=open]:border-nb-gray-800/50 data-[state=open]:bg-nb-gray-900/30 data-[state=open]:text-nb-gray-50", ], ghost: [ - "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", - "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", + "ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20", + "border-transparent bg-transparent text-nb-gray-400 hover:bg-nb-gray-900/30 hover:text-nb-gray-50", ], danger: [ + "bg-red-600 text-red-50 hover:bg-red-700 focus:bg-red-700 focus:ring-red-700/20", "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20", ], }, diff --git a/client/ui/frontend/src/components/buttons/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx index 3d36bc111..8d688cb0b 100644 --- a/client/ui/frontend/src/components/buttons/IconButton.tsx +++ b/client/ui/frontend/src/components/buttons/IconButton.tsx @@ -24,7 +24,7 @@ export const IconButton = forwardRef(function IconButt "flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none", "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "wails-no-draggable transition-colors duration-150", className, )} diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx index fa8007d9f..c43c04b0b 100644 --- a/client/ui/frontend/src/components/dialog/Dialog.tsx +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -23,7 +23,7 @@ const Overlay = forwardRef, OverlayPr ref={ref} className={cn( "fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16", - "bg-black/60", + "bg-black/25 dark:bg-black/60", "data-[state=open]:animate-in data-[state=open]:fade-in-0", exitAnimation && "data-[state=closed]:animate-out data-[state=closed]:fade-out-0", @@ -67,7 +67,7 @@ export const Content = forwardRef, Co className={cn( "relative z-[52] mx-auto w-full outline-none ring-0", "focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0", - "rounded-lg border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl", + "rounded-lg border border-nb-gray-800 bg-nb-gray-940 py-7 shadow-2xl dark:border-nb-gray-900 dark:bg-nb-gray", "data-[state=open]:animate-in data-[state=open]:fade-in-0", "data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1", exitAnimation && diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx index 2dad80d7a..eada79a1f 100644 --- a/client/ui/frontend/src/components/inputs/Input.tsx +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -32,19 +32,19 @@ const inputVariants = cva("", { variants: { variant: { default: [ - "border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "border-neutral-200 placeholder:text-nb-gray-600 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", ], darker: [ - "border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70", + "border-neutral-300 placeholder:text-nb-gray-600 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70", "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", ], error: [ - "border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "border-neutral-200 text-red-500 placeholder:text-nb-gray-600 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", "ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10", ], warning: [ - "border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "border-neutral-200 text-orange-400 placeholder:text-nb-gray-600 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", "ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10", ], }, @@ -158,7 +158,7 @@ function NumberStepper({ className={cn( "flex h-[40px] shrink-0 flex-col overflow-hidden", "rounded-r-md border border-l-0", - "border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900", + "border-neutral-200 bg-white dark:border-nb-gray-700 dark:bg-nb-gray-900", error && "dark:border-red-500", disabled && "pointer-events-none opacity-40", )} @@ -274,7 +274,9 @@ export const Input = forwardRef(function Input(