From e0c25ba4ba9766716e5d532af730f0ac8c31a6c6 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Thu, 9 Jul 2026 18:17:28 +0200 Subject: [PATCH 01/14] [client] fix flaky test around event aggregation (#6710) * fix flaky test around event aggregation: control time.Now() from the test Signed-off-by: Dmitri Dolguikh * actually use passed in func to generate time Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- .../netflow/store/event_aggregation_test.go | 5 ++++- client/internal/netflow/store/memory.go | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/client/internal/netflow/store/event_aggregation_test.go b/client/internal/netflow/store/event_aggregation_test.go index c0422e8b7..8abe0d162 100644 --- a/client/internal/netflow/store/event_aggregation_test.go +++ b/client/internal/netflow/store/event_aggregation_test.go @@ -175,7 +175,9 @@ func TestFlowAggregationOfUnknownProtocols(t *testing.T) { } func TestResetAggregationWindow(t *testing.T) { - store := NewAggregatingMemoryStore() + now := time.Now() + nowFunc := func() time.Time { return now } + store := NewAggregatingMemoryStoreWithTimeFunc(nowFunc) store.StoreEvent(&types.Event{ ID: uuid.New(), Timestamp: time.Now(), @@ -198,6 +200,7 @@ func TestResetAggregationWindow(t *testing.T) { }, }) + now = now.Add(1 * time.Second) reset := store.ResetAggregationWindow() previousEvents, ok := reset.(*AggregatingMemory) assert.True(t, ok) diff --git a/client/internal/netflow/store/memory.go b/client/internal/netflow/store/memory.go index a34e4be63..dfe764032 100644 --- a/client/internal/netflow/store/memory.go +++ b/client/internal/netflow/store/memory.go @@ -29,6 +29,7 @@ type AggregatingMemory struct { WindowStart time.Time WindowEnd time.Time rnd *v2.PCG + nowFunc func() time.Time } func (m *Memory) StoreEvent(event *types.Event) { @@ -62,14 +63,19 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) { } func NewAggregatingMemoryStore() *AggregatingMemory { - return &AggregatingMemory{WindowStart: time.Now(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} + return NewAggregatingMemoryStoreWithTimeFunc(defaultNowFunc) +} + +// used in tests when deterministic (less random) time intervals are required +func NewAggregatingMemoryStoreWithTimeFunc(nowFunc func() time.Time) *AggregatingMemory { + return &AggregatingMemory{WindowStart: nowFunc(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, nowFunc: nowFunc, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} } func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator { am.mux.Lock() defer am.mux.Unlock() - now := time.Now() + now := am.nowFunc() toret := AggregatingMemory{WindowStart: am.WindowStart, WindowEnd: now, Memory: Memory{events: am.events}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} am.events = make(map[uuid.UUID]*types.Event) @@ -152,3 +158,7 @@ func (am *AggregatingMemory) GetAggregatedEvents() []*types.Event { return slices.Collect(maps.Values(aggregated)) // could return an iterator instead here } + +func defaultNowFunc() time.Time { + return time.Now() +} From 8e02154bf566a722bde287e4a8697147b3fc2997 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 10 Jul 2026 16:11:27 +0200 Subject: [PATCH 02/14] [client] Add SSO login flow timing instrumentation (#6717) Users reported long delays between finishing browser authentication and the client connecting. Logs could not attribute the time: the PKCE and device flows were silent between issuing the auth URL and returning the token, and nothing recorded when the GUI issued the Up request after WaitSSOLogin completed. Add log lines covering the full chain: PKCE callback arrival and token exchange duration, device-flow polling and approval timing, GUI-side brackets around WaitSSOLogin and Up, daemon-side Up arrival and WaitSSOLogin return, and a frontend stall detector that reports when webview timers were suspended (macOS App Nap / hidden-window throttling), which delays the WaitSSOLogin-to-Up handoff. --- client/internal/auth/device_flow.go | 9 +++++++ client/internal/auth/pkce_flow.go | 12 ++++++++- client/server/server.go | 2 ++ client/ui/frontend/src/app.tsx | 3 +++ client/ui/frontend/src/lib/stallwatch.ts | 31 ++++++++++++++++++++++++ client/ui/services/connection.go | 4 +++ 6 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 client/ui/frontend/src/lib/stallwatch.ts diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go index e33765300..8d90fb82f 100644 --- a/client/internal/auth/device_flow.go +++ b/client/internal/auth/device_flow.go @@ -259,12 +259,18 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn ticker := time.NewTicker(interval) defer ticker.Stop() + log.Infof("device flow: waiting for user authorization, polling token endpoint every %s, code expires in %s", interval, timeout) + + start := time.Now() + polls := 0 + for { select { case <-waitCtx.Done(): return TokenInfo{}, waitCtx.Err() case <-ticker.C: + polls++ tokenResponse, err := d.requestToken(info) if err != nil { return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err) @@ -272,10 +278,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn if tokenResponse.Error != "" { if tokenResponse.Error == "authorization_pending" { + log.Tracef("device flow: authorization still pending after poll %d", polls) continue } else if tokenResponse.Error == "slow_down" { interval += (3 * time.Second) ticker.Reset(interval) + log.Infof("device flow: IdP requested slow_down, polling interval increased to %s", interval) continue } @@ -296,6 +304,7 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err) } + log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second)) return tokenInfo, err } } diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go index 84fa8a214..d0df2b122 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -188,6 +188,8 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo waitCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + log.Infof("pkce flow: waiting for authorization callback on %s, timeout %s", p.oAuthConfig.RedirectURL, timeout) + tokenChan := make(chan *oauth2.Token, 1) errChan := make(chan error, 1) @@ -221,6 +223,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo func (p *PKCEAuthorizationFlow) startServer(server *http.Server, tokenChan chan<- *oauth2.Token, errChan chan<- error) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + log.Infof("pkce flow: received authorization callback from IdP") cert := p.providerConfig.ClientCertPair if cert != nil { tr := &http.Transport{ @@ -271,11 +274,18 @@ func (p *PKCEAuthorizationFlow) handleRequest(req *http.Request) (*oauth2.Token, return nil, fmt.Errorf("authentication failed: missing code") } - return p.oAuthConfig.Exchange( + exchangeStart := time.Now() + token, err := p.oAuthConfig.Exchange( req.Context(), code, oauth2.SetAuthURLParam("code_verifier", p.codeVerifier), ) + if err != nil { + return nil, err + } + + log.Infof("pkce flow: authorization code exchanged for token in %s", time.Since(exchangeStart).Round(time.Millisecond)) + return token, nil } func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, error) { diff --git a/client/server/server.go b/client/server/server.go index 363f716a9..46f9a6055 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -828,6 +828,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin return nil, err } + log.Infof("SSO login flow finished, returning success to caller") return &proto.WaitSSOLoginResponse{ Email: tokenInfo.Email, }, nil @@ -835,6 +836,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin // Up starts engine work in the daemon. func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) { + log.Infof("up request received") s.mutex.Lock() // clientRunning is the daemon-intent flag (set by previous Up/Start, cleared // by Down). connectionGoroutineRunning() reports whether the previous retry-loop diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index c7b12e538..7f1359510 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -16,10 +16,13 @@ import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowser import { initI18n } from "@/lib/i18n"; import { initPlatform } from "@/lib/platform"; import { initLogForwarding } from "@/lib/logs"; +import { initStallWatch } from "@/lib/stallwatch"; // Must run first so even init-time logs reach the Go log pipeline. initLogForwarding(); +initStallWatch(); + welcome(); Promise.all([ diff --git a/client/ui/frontend/src/lib/stallwatch.ts b/client/ui/frontend/src/lib/stallwatch.ts new file mode 100644 index 000000000..aca7d75bb --- /dev/null +++ b/client/ui/frontend/src/lib/stallwatch.ts @@ -0,0 +1,31 @@ +// Detects webview suspension (macOS App Nap / hidden-window timer throttling). +// While the webview is suspended no JS runs at all, so detection happens on +// resume: a 1s interval measures wall-clock drift and reports how long timers +// were frozen. Silent unless a stall actually occurred; a stalled webview is +// what delays promise continuations such as the WaitSSOLogin → Up handoff. + +const INTERVAL_MS = 1000; +const STALL_THRESHOLD_MS = 5000; +const REPORT_COOLDOWN_MS = 60_000; + +let started = false; + +export function initStallWatch() { + if (started) return; + started = true; + + let last = Date.now(); + let lastReport = 0; + setInterval(() => { + const now = Date.now(); + const stall = now - last - INTERVAL_MS; + last = now; + if (stall < STALL_THRESHOLD_MS) return; + if (now - lastReport < REPORT_COOLDOWN_MS) return; + lastReport = now; + console.warn( + `webview timers were suspended for ${(stall / 1000).toFixed(1)}s ` + + `(App Nap / hidden-window throttling); pending UI work ran late`, + ); + }, INTERVAL_MS); +} diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index a23a526e6..8e7919af6 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -116,6 +116,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err if err != nil { return LoginResult{}, s.classifyDaemonError(err) } + log.Infof("daemon login response received, needs SSO login: %v", resp.GetNeedsSSOLogin()) return LoginResult{ NeedsSSOLogin: resp.GetNeedsSSOLogin(), UserCode: resp.GetUserCode(), @@ -129,6 +130,7 @@ func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, if err != nil { return "", err } + log.Infof("waiting for SSO login to complete") resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{ UserCode: p.UserCode, Hostname: p.Hostname, @@ -136,6 +138,7 @@ func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, if err != nil { return "", s.classifyDaemonError(err) } + log.Infof("SSO login completed, daemon reported success") return resp.GetEmail(), nil } @@ -144,6 +147,7 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error { if err != nil { return err } + log.Infof("sending up request to daemon") // Always async: status updates flow via SubscribeStatus. req := &proto.UpRequest{Async: true} if p.ProfileName != "" { From 30d15ecc3d9bf69161f8a8eda597acb78a29b602 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sat, 11 Jul 2026 11:03:55 +0200 Subject: [PATCH 03/14] [client,management] sync 0.74.4 changes (#6727) * [management] fix: prevent reverse proxy domain from being pushed as DNS search domain by @blaugrau90 in https://github.com/netbirdio/netbird/pull/6498 * [client] Recover from rosenpass key desync by @lixmal in https://github.com/netbirdio/netbird/pull/6714 * [client] Bump golang.org/x/crypto to v0.54.0 by @lixmal in https://github.com/netbirdio/netbird/pull/6709 * [client] fix MDM managementURL conflict on default-port URL echo by @riccardomanfrin in https://github.com/netbirdio/netbird/pull/6672 * [client] Update gopsutil to v4 by @mlsmaycon in https://github.com/netbirdio/netbird/pull/6688 * [client] Fix hanging status command during relay dial by @theodorsm in https://github.com/netbirdio/netbird/pull/6694 --------- Co-authored-by: Theodor Midtlien Co-authored-by: blaugrau90 <61945343+blaugrau90@users.noreply.github.com> Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com> --- client/internal/engine.go | 2 +- client/internal/peer/conn.go | 42 ++- client/internal/peer/conn_test.go | 82 ++++++ client/internal/peer/wg_watcher.go | 14 +- client/internal/peer/wg_watcher_test.go | 72 ++++- client/internal/rosenpass/manager.go | 11 +- client/internal/rosenpass/manager_test.go | 26 +- client/internal/rosenpass/netbird_handler.go | 166 +++++++++--- .../rosenpass/netbird_handler_test.go | 250 ++++++++++++++++++ client/internal/rosenpass/seed.go | 17 ++ go.mod | 18 +- go.sum | 32 +-- management/internals/server/server.go | 3 +- management/server/types/account.go | 3 +- signal/cmd/run.go | 3 +- 15 files changed, 649 insertions(+), 92 deletions(-) create mode 100644 client/internal/rosenpass/netbird_handler_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index 4367f68b0..7b2fc7b26 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -551,7 +551,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } else { log.Infof("running rosenpass in strict mode") } - e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName) + e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey) if err != nil { return fmt.Errorf("create rosenpass manager: %w", err) } diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index fb468696f..f0625c853 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -30,6 +30,11 @@ import ( relayClient "github.com/netbirdio/netbird/shared/relay/client" ) +// wgTimeoutEscalationThreshold is the number of consecutive WireGuard +// handshake timeouts after which the rosenpass state for the peer is +// considered desynced and gets reset. +const wgTimeoutEscalationThreshold = 3 + // MetricsRecorder is an interface for recording peer connection metrics type MetricsRecorder interface { RecordConnectionStages( @@ -118,6 +123,9 @@ type Conn struct { wgWatcher *WGWatcher wgWatcherWg sync.WaitGroup wgWatcherCancel context.CancelFunc + // wgTimeouts counts consecutive WireGuard handshake timeouts without a + // successful handshake in between. Guarded by mu. + wgTimeouts int // used to store the remote Rosenpass key for Relayed connection in case of connection update from ice rosenpassRemoteKey []byte @@ -683,6 +691,29 @@ func (conn *Conn) onWGDisconnected() { default: conn.Log.Debugf("No active connection to close on WG timeout") } + + conn.escalateWGTimeoutLocked() +} + +// escalateWGTimeoutLocked resets the peer's rosenpass state after repeated +// handshake timeouts. With rosenpass enabled, persistent timeouts mean the +// preshared keys have desynced; the renewal exchange runs over the dead +// tunnel and cannot resync them. Reporting the peer disconnected drops its +// rosenpass state, so the next connection configuration programs the +// rendezvous key and the tunnel can bootstrap again. Callers must hold mu. +func (conn *Conn) escalateWGTimeoutLocked() { + if conn.config.RosenpassConfig.PubKey == nil { + return + } + + conn.wgTimeouts++ + if conn.wgTimeouts < wgTimeoutEscalationThreshold || conn.onDisconnected == nil { + return + } + conn.wgTimeouts = 0 + + conn.Log.Warnf("%d consecutive WireGuard handshake timeouts, resetting rosenpass state for peer", wgTimeoutEscalationThreshold) + conn.onDisconnected(conn.config.WgConfig.RemoteKey) } func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) { @@ -812,7 +843,7 @@ func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) { conn.wgWatcherWg.Add(1) go func() { defer conn.wgWatcherWg.Done() - conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess) + conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess) }() } @@ -892,6 +923,15 @@ func (conn *Conn) onWGHandshakeSuccess(when time.Time) { conn.recordConnectionMetrics() } +// onWGCheckSuccess is called for every watcher check that observed a fresh +// handshake, including handshakes of connections that were already up when +// the watcher started. +func (conn *Conn) onWGCheckSuccess() { + conn.mu.Lock() + conn.wgTimeouts = 0 + conn.mu.Unlock() +} + // recordConnectionMetrics records connection stage timestamps as metrics func (conn *Conn) recordConnectionMetrics() { if conn.metricsRecorder == nil { diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 59216b647..f2312a66a 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/client/iface" @@ -304,3 +305,84 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) { t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK") } } + +func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn { + cfg := ConnConfig{ + Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="}, + } + if rosenpassEnabled { + cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")} + } + + conn := &Conn{ + ctx: context.Background(), + config: cfg, + Log: log.WithField("peer", cfg.Key), + metricsStages: &MetricsStages{}, + } + conn.SetOnDisconnected(func(remotePeer string) { + *disconnected = append(*disconnected, remotePeer) + }) + return conn +} + +// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake +// timeouts with rosenpass enabled mean the preshared keys have desynced. The +// renewal exchange runs over the dead tunnel and cannot resync them, so after +// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the +// peer disconnected, dropping its rosenpass state so the next configuration +// programs the rendezvous key. +func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + assert.Empty(t, disconnected, "escalation must not fire below the threshold") + + conn.onWGDisconnected() + assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected, + "reaching the threshold must report the peer disconnected once") + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + assert.Len(t, disconnected, 1, "escalation must restart counting after firing") + + conn.onWGDisconnected() + assert.Len(t, disconnected, 2, "continued timeouts must escalate again") +} + +// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful +// handshake between timeouts means the tunnel recovered; the counter must +// start over. +func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + conn.onWGCheckSuccess() + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected() + } + assert.Empty(t, disconnected, "handshake success must reset the timeout count") +} + +// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass +// there is no per-peer key state to reset; repeated timeouts must not report +// disconnects. +func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(false, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold*3; i++ { + conn.onWGDisconnected() + } + assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections") +} diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index 4fc883d17..10c22153f 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -71,9 +71,11 @@ func (w *WGWatcher) PrepareInitialHandshake() (ok bool) { // EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by // PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible -// for context lifecycle management. -func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) { - w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake) +// for context lifecycle management. onHandshakeSuccessFn is called only for the first +// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh +// handshake, including the first. +func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) { + w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake) w.muEnabled.Lock() w.enabled = false @@ -90,7 +92,7 @@ func (w *WGWatcher) Reset() { } // wgStateCheck help to check the state of the WireGuard handshake and relay connection -func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) { +func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) { w.log.Infof("WireGuard watcher started") timer := time.NewTimer(wgHandshakeOvertime) @@ -117,6 +119,10 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn } } + if onCheckSuccessFn != nil && ctx.Err() == nil { + onCheckSuccessFn() + } + lastHandshake = *handshake resetTime := time.Until(handshake.Add(checkPeriod)) diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index 634d7974f..80f34f1a1 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -24,6 +24,72 @@ func (m *MocWgIface) disconnect() { m.stop = true } +type mockHandshakeStats struct { + mu sync.Mutex + handshake time.Time +} + +func (m *mockHandshakeStats) GetStats() (map[string]configurer.WGStats, error) { + m.mu.Lock() + defer m.mu.Unlock() + return map[string]configurer.WGStats{"": {LastHandshake: m.handshake}}, nil +} + +func (m *mockHandshakeStats) advance() { + m.mu.Lock() + defer m.mu.Unlock() + m.handshake = time.Now() +} + +// TestWGWatcher_CheckSuccessCallback: onCheckSuccessFn must fire for a fresh +// handshake even when the watcher started with an existing handshake baseline, +// the case where onHandshakeSuccessFn stays silent. +func TestWGWatcher_CheckSuccessCallback(t *testing.T) { + // checkPeriod bounds how stale a handshake may be before the watcher treats it + // as a suspended-machine timeout. The first check fires after wgHandshakeOvertime, + // so keep checkPeriod well above any scheduling jitter to avoid a false timeout + // converting the expected success into a disconnect on a loaded runner. + checkPeriod = 1 * time.Minute + wgHandshakeOvertime = 1 * time.Second + + mlog := log.WithField("peer", "tet") + // Use an old baseline so advance() yields a strictly newer handshake even on + // platforms with coarse clock resolution (Windows), where two time.Now() calls + // microseconds apart can return the same instant and read as a timed-out handshake. + stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)} + watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{})) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + require.True(t, watcher.PrepareInitialHandshake()) + + firstHandshake := make(chan struct{}, 1) + checkSuccess := make(chan struct{}, 1) + go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) { + firstHandshake <- struct{}{} + }, func() { + select { + case checkSuccess <- struct{}{}: + default: + } + }) + + stats.advance() + + select { + case <-checkSuccess: + case <-time.After(10 * time.Second): + t.Errorf("timeout waiting for check success callback") + } + + select { + case <-firstHandshake: + t.Errorf("first-handshake callback must not fire for a non-zero baseline") + default: + } +} + func TestWGWatcher_EnableWgWatcher(t *testing.T) { checkPeriod = 5 * time.Second wgHandshakeOvertime = 1 * time.Second @@ -44,7 +110,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { onDisconnected <- struct{}{} }, func(when time.Time) { mlog.Infof("onHandshakeSuccess: %v", when) - }) + }, nil) // wait for initial reading time.Sleep(2 * time.Second) @@ -73,7 +139,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}) + watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil) }() cancel() @@ -89,7 +155,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { onDisconnected <- struct{}{} - }, func(when time.Time) {}) + }, func(when time.Time) {}, nil) time.Sleep(2 * time.Second) mocWgIface.disconnect() diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go index 903753753..21dd751df 100644 --- a/client/internal/rosenpass/manager.go +++ b/client/internal/rosenpass/manager.go @@ -39,6 +39,7 @@ type rpServer interface { type Manager struct { ifaceName string + localWgKey wgtypes.Key spk []byte ssk []byte rpKeyHash string @@ -51,8 +52,9 @@ type Manager struct { wgIface PresharedKeySetter } -// NewManager creates a new Rosenpass manager -func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) { +// NewManager creates a new Rosenpass manager. localWgKey is the local +// WireGuard public key, used to derive the per-peer rendezvous key. +func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) { public, secret, err := rp.GenerateKeyPair() if err != nil { return nil, err @@ -62,6 +64,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash) return &Manager{ ifaceName: wgIfaceName, + localWgKey: localWgKey, rpKeyHash: rpKeyHash, spk: public, ssk: secret, @@ -73,7 +76,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) // nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will // replace it with a fresh handler on each Run() to clear stale peer // state from previous engine sessions. - rpWgHandler: NewNetbirdHandler(), + rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey), lock: sync.Mutex{}, }, nil } @@ -161,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) { cfg.Peers = []rp.PeerConfig{} m.lock.Lock() - m.rpWgHandler = NewNetbirdHandler() + m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey) if m.wgIface != nil { m.rpWgHandler.SetInterface(m.wgIface) } diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go index d74960d0d..69e18ac88 100644 --- a/client/internal/rosenpass/manager_test.go +++ b/client/internal/rosenpass/manager_test.go @@ -85,7 +85,7 @@ func newTestManager(spkFirstByte byte, mock *mockServer) *Manager { ssk: make([]byte, 32), rpKeyHash: "test-hash", rpPeerIDs: make(map[string]*rp.PeerID), - rpWgHandler: NewNetbirdHandler(), + rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}), server: mock, } } @@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) { // issue #4341 cannot occur in the window between NewManager and Run(). func TestNewManager_PreInitializesHandler(t *testing.T) { psk := wgtypes.Key{} - m, err := NewManager(&psk, "wt0") + m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01}) require.NoError(t, err) require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager") } @@ -329,10 +329,10 @@ func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing require.False(t, m.IsPresharedKeyInitialized(wgKey)) } -// --- NetbirdHandler.outputKey ---------------------------------------------- +// --- NetbirdHandler.applyKey ---------------------------------------------- -func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -348,8 +348,8 @@ func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { require.Equal(t, wgKey.String(), iface.calls[0].peerKey) } -func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -364,8 +364,8 @@ func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true") } -func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) // no SetInterface — iface remains nil pid := rp.PeerID{0x03} h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{})) @@ -374,8 +374,8 @@ func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) { h.HandshakeCompleted(pid, rp.Key{}) } -func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -384,7 +384,7 @@ func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) { } func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) { - h := NewNetbirdHandler() + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -398,7 +398,7 @@ func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) { } func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) { - h := NewNetbirdHandler() + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) pid := rp.PeerID{0x05} wgKey := wgtypes.Key{0xEE} h.AddPeer(pid, "wt0", rp.Key(wgKey)) diff --git a/client/internal/rosenpass/netbird_handler.go b/client/internal/rosenpass/netbird_handler.go index 9de2409ef..672650ca7 100644 --- a/client/internal/rosenpass/netbird_handler.go +++ b/client/internal/rosenpass/netbird_handler.go @@ -18,19 +18,34 @@ type PresharedKeySetter interface { type wireGuardPeer struct { Interface string PublicKey rp.Key + // initialized is true once a completed exchange has set a + // Rosenpass-managed PSK for this peer. + initialized bool + // chainKey is the key output by the last completed exchange, advanced by + // one ratchet step on expiry. Nil until the first exchange completes and + // after the peer has fallen back to the rendezvous key. + chainKey *wgtypes.Key + // expiries counts failed renewals since the last completed exchange. + expiries int } type NetbirdHandler struct { - mu sync.Mutex - iface PresharedKeySetter - peers map[rp.PeerID]wireGuardPeer - initializedPeers map[rp.PeerID]bool + mu sync.Mutex + iface PresharedKeySetter + // preSharedKey is the account-level preshared key, used as the rendezvous + // key when set. Nil means the deterministic seed key is used instead. + preSharedKey *[32]byte + // localWgKey is the local WireGuard public key, one of the two inputs to + // the deterministic seed key. + localWgKey wgtypes.Key + peers map[rp.PeerID]*wireGuardPeer } -func NewNetbirdHandler() *NetbirdHandler { +func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler { return &NetbirdHandler{ - peers: map[rp.PeerID]wireGuardPeer{}, - initializedPeers: map[rp.PeerID]bool{}, + preSharedKey: preSharedKey, + localWgKey: localWgKey, + peers: map[rp.PeerID]*wireGuardPeer{}, } } @@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) { h.iface = iface } +// AddPeer registers a peer with the handler. Re-adding a known peer (every +// reconnection does) keeps its key recovery state. func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) { h.mu.Lock() defer h.mu.Unlock() - h.peers[pid] = wireGuardPeer{ + if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk { + existing.Interface = intf + return + } + h.peers[pid] = &wireGuardPeer{ Interface: intf, PublicKey: pk, } @@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { h.mu.Lock() defer h.mu.Unlock() delete(h.peers, pid) - delete(h.initializedPeers, pid) } // IsPeerInitialized returns true if Rosenpass has completed a handshake @@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool { h.mu.Lock() defer h.mu.Unlock() - return h.initializedPeers[pid] + peer, ok := h.peers[pid] + return ok && peer.initialized } +// HandshakeCompleted programs the freshly exchanged output key and resets the +// peer's key recovery state. func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) { - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} + psk := wgtypes.Key(key) -func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { - key, _ := rp.GeneratePresharedKey() - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} - -func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) { h.mu.Lock() - iface := h.iface - wg, ok := h.peers[pid] - isInitialized := h.initializedPeers[pid] - h.mu.Unlock() + defer h.mu.Unlock() - if iface == nil { - log.Warn("rosenpass: interface not set, cannot update preshared key") + peer, ok := h.peers[pid] + if !ok { return } + if peer.expiries > 0 { + log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries) + } + // chainKey tracks the shared exchange output regardless of the local write + // outcome, so both ends still converge on the next expiry. + peer.chainKey = &psk + peer.expiries = 0 + if !h.applyKeyLocked(pid, psk, peer.initialized) { + return + } + peer.initialized = true +} +// HandshakeExpired replaces the expired key. The renewal exchange runs over +// the tunnel keyed by the PSK itself, so the replacement must be derivable on +// both ends without communication: the first expiry ratchets the last shared +// key forward, repeated expiries (and expiries without a completed exchange) +// fall back to the rendezvous key and drop the peer out of the initialized +// state so connection reconfigurations reprogram the rendezvous key as well. +func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { + h.mu.Lock() + defer h.mu.Unlock() + + peer, ok := h.peers[pid] if !ok { return } - peerKey := wgtypes.Key(wg.PublicKey).String() - pskKey := wgtypes.Key(psk) + peer.expiries++ - // Use updateOnly=true for later rotations (peer already has Rosenpass PSK) - // Use updateOnly=false for first rotation (peer has original/empty PSK) - if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil { + var psk wgtypes.Key + if peer.chainKey != nil && peer.expiries == 1 { + log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey)) + psk = RatchetKey(*peer.chainKey) + peer.chainKey = &psk + } else { + rendezvous, err := h.rendezvousKey(peer) + if err != nil { + // Fail closed: without a rendezvous key the expired key must + // still be rotated out, even if the replacement is unusable. + log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err) + h.applyRandomKeyLocked(pid) + return + } + log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries) + psk = rendezvous + peer.chainKey = nil + peer.initialized = false + } + + h.applyKeyLocked(pid, psk, true) +} + +// rendezvousKey returns the key both ends converge on without communication: +// the account-level preshared key when configured, the deterministic seed key +// otherwise. It mirrors the key that peer connections program when Rosenpass +// does not manage the peer yet. +func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) { + if h.preSharedKey != nil { + return *h.preSharedKey, nil + } + + seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String()) + if err != nil { + return wgtypes.Key{}, err + } + return *seed, nil +} + +// applyKeyLocked writes the preshared key for the peer to the WireGuard +// interface and reports whether the write succeeded. Callers must hold h.mu +// for the whole state-mutation-plus-write so that a concurrent completion and +// expiry cannot reorder their writes relative to the in-memory chain key. +func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool { + peer, ok := h.peers[pid] + if !ok { + return false + } + + if h.iface == nil { + log.Warn("rosenpass: interface not set, cannot update preshared key") + return false + } + + peerKey := wgtypes.Key(peer.PublicKey).String() + if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil { log.Errorf("Failed to apply rosenpass key: %v", err) + return false + } + + return true +} + +func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) { + key, err := rp.GeneratePresharedKey() + if err != nil { + log.Errorf("failed to generate random preshared key: %v", err) return } - - // Mark peer as isInitialized after the successful first rotation - if !isInitialized { - h.mu.Lock() - if _, exists := h.peers[pid]; exists { - h.initializedPeers[pid] = true - } - h.mu.Unlock() - } + h.applyKeyLocked(pid, wgtypes.Key(key), true) } diff --git a/client/internal/rosenpass/netbird_handler_test.go b/client/internal/rosenpass/netbird_handler_test.go new file mode 100644 index 000000000..9d91ba93b --- /dev/null +++ b/client/internal/rosenpass/netbird_handler_test.go @@ -0,0 +1,250 @@ +package rosenpass + +import ( + "testing" + + rp "cunicu.li/go-rosenpass" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// handlerTestLink wires two NetbirdHandlers as the two ends of a single +// tunnel: handler A manages the rosenpass peer B and vice versa, the way two +// NetBird clients see each other. +type handlerTestLink struct { + handlerA, handlerB *NetbirdHandler + ifaceA, ifaceB *mockIface + pidA, pidB rp.PeerID + wgKeyA, wgKeyB wgtypes.Key +} + +func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink { + t.Helper() + + link := &handlerTestLink{ + ifaceA: &mockIface{}, + ifaceB: &mockIface{}, + } + link.pidA[0] = 0xaa + link.pidB[0] = 0xbb + link.wgKeyA[31] = 1 + link.wgKeyB[31] = 2 + + link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA) + link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB) + + link.handlerA.SetInterface(link.ifaceA) + link.handlerB.SetInterface(link.ifaceB) + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA)) + + return link +} + +// complete simulates a completed rosenpass exchange: both ends derive the +// same output key. +func (l *handlerTestLink) complete(osk rp.Key) { + l.handlerA.HandshakeCompleted(l.pidB, osk) + l.handlerB.HandshakeCompleted(l.pidA, osk) +} + +// expire simulates a failed key renewal on both ends. +func (l *handlerTestLink) expire() { + l.handlerA.HandshakeExpired(l.pidB) + l.handlerB.HandshakeExpired(l.pidA) +} + +func lastPSK(t *testing.T, m *mockIface) wgtypes.Key { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call") + return m.calls[len(m.calls)-1].psk +} + +func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk") + require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange") + + link.complete(osk) + require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only") +} + +// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant: +// rosenpass renewals run over the tunnel that the PSK itself keys, so when a +// renewal fails on both ends, both ends must fall back to the same key or the +// tunnel can never handshake again. +func TestHandshakeExpired_BothSidesConverge(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + keyA := lastPSK(t, link.ifaceA) + keyB := lastPSK(t, link.ifaceB) + require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out") + require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must still converge after repeated expiries") +} + +// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap +// case: the initial exchange never completed (the tunnel ran on the rendezvous +// key), so an expiry must not replace the working key with an unrecoverable +// one on either end. +func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) { + link := newHandlerTestLink(t, nil) + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must converge when the exchange never completed") +} + +// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep +// failing, the peer must drop out of the initialized state so the next +// connection reconfiguration reprograms the rendezvous key instead of +// preserving a poisoned rosenpass-managed key. +func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.False(t, link.handlerA.IsPeerInitialized(link.pidB), + "repeated expiries must clear the initialized state") + require.False(t, link.handlerB.IsPeerInitialized(link.pidA), + "repeated expiries must clear the initialized state") +} + +// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a +// desync must fully reset the recovery state. +func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk1, osk2 rp.Key + osk1[0] = 1 + osk2[0] = 2 + + link.complete(osk1) + link.expire() + link.expire() + + link.complete(osk2) + require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "recovered link must converge again on the next expiry") + require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out") +} + +// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must +// derive the replacement from the last shared key, so an attacker who only +// blocks the renewal exchange gains nothing over the previous key. +func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA), + "first expiry must program the ratcheted key") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "ratchet step must keep the peer initialized so reconfigurations preserve the key") +} + +// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key +// also fails, both ends must land on the same key that peer connections +// program for uninitialized peers, so a reconnect completes the recovery. +func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key") + require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key") +} + +// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level +// preshared key configured, the fallback must be that key, matching what peer +// connections program for uninitialized peers. +func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) { + psk := &[32]byte{0x77} + link := newHandlerTestLink(t, psk) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA), + "fallback must be the configured preshared key") + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB), + "fallback must be the configured preshared key on both ends") +} + +// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must +// never create a WireGuard peer that connection management has removed. +func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + for _, call := range link.ifaceA.calls[1:] { + require.True(t, call.updateOnly, "expiry writes must be update-only") + } +} + +// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every +// OnConnected; that must not reset the expiry chain state. +func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + link.expire() + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "re-adding a known peer must keep its state") + + link.expire() + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), + "second expiry after re-add must continue to the seed fallback") +} diff --git a/client/internal/rosenpass/seed.go b/client/internal/rosenpass/seed.go index 83aba1e0e..052c11ed4 100644 --- a/client/internal/rosenpass/seed.go +++ b/client/internal/rosenpass/seed.go @@ -1,11 +1,28 @@ package rosenpass import ( + "crypto/sha256" "fmt" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +// ratchetLabel domain-separates the expiry ratchet from other uses of the +// rosenpass output key. +const ratchetLabel = "netbird-rosenpass-expiry-ratchet" + +// RatchetKey derives the successor preshared key from the previous Rosenpass +// output key. When a key expires without a completed renewal, both peers +// advance their last shared key by one ratchet step: the expired key is +// rotated out while both ends still converge on an identical, non-public +// replacement without communicating. +func RatchetKey(prev wgtypes.Key) wgtypes.Key { + input := make([]byte, 0, len(ratchetLabel)+len(prev)) + input = append(input, ratchetLabel...) + input = append(input, prev[:]...) + return sha256.Sum256(input) +} + // DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair // of peer public keys. Both peers, given the same key pair, produce the same // output regardless of which side runs the function: the inputs are ordered diff --git a/go.mod b/go.mod index dbbd3e35b..b90b68446 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/netbirdio/netbird go 1.25.5 -toolchain go1.25.11 +toolchain go1.25.12 require ( cunicu.li/go-rosenpass v0.5.42 @@ -19,8 +19,8 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.50.0 - golang.org/x/sys v0.43.0 + golang.org/x/crypto v0.54.0 + golang.org/x/sys v0.47.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -126,11 +126,11 @@ require ( goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/mobile v0.0.0-20251113184115-a159579294ab - golang.org/x/mod v0.35.0 - golang.org/x/net v0.53.0 + golang.org/x/mod v0.37.0 + golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.20.0 - golang.org/x/term v0.42.0 + golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 google.golang.org/api v0.276.0 gopkg.in/yaml.v3 v3.0.1 @@ -314,8 +314,8 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index d4be37df0..ad43dc109 100644 --- a/go.sum +++ b/go.sum @@ -732,8 +732,8 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= @@ -748,8 +748,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -768,8 +768,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -784,8 +784,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -821,8 +821,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -835,8 +835,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -848,8 +848,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -863,8 +863,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/management/internals/server/server.go b/management/internals/server/server.go index 9411073ac..7fd06d947 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -15,7 +15,7 @@ import ( "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/acme/autocert" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/http2/h2c" //nolint:staticcheck "google.golang.org/grpc" "github.com/netbirdio/netbird/encryption" @@ -382,6 +382,7 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene // the following magic is needed to support HTTP2 without TLS // and still share a single port between gRPC and HTTP APIs h1s := &http.Server{ + //nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not Handler: h2c.NewHandler(handler, &http2.Server{}), } err = h1s.Serve(listener) diff --git a/management/server/types/account.go b/management/server/types/account.go index 7a0a0054f..6be865a43 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -305,7 +305,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon zone = &nbdns.CustomZone{ Domain: dns.Fqdn(serviceDomainZone), Records: []nbdns.SimpleRecord{}, - NonAuthoritative: true, + NonAuthoritative: true, + SearchDomainDisabled: true, } zonesByApex[serviceDomainZone] = zone } diff --git a/signal/cmd/run.go b/signal/cmd/run.go index 681222403..81e9cc926 100644 --- a/signal/cmd/run.go +++ b/signal/cmd/run.go @@ -16,7 +16,7 @@ import ( "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/acme/autocert" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/http2/h2c" //nolint:staticcheck "github.com/netbirdio/netbird/shared/metrics" @@ -281,6 +281,7 @@ func serveHTTP(httpListener net.Listener, handler http.Handler) { go func() { // Use h2c to support HTTP/2 without TLS (needed for gRPC) h1s := &http.Server{ + //nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not Handler: h2c.NewHandler(handler, &http2.Server{}), } err := h1s.Serve(httpListener) From fd94fdb42bad60824a712d19481df8be3237726a Mon Sep 17 00:00:00 2001 From: Sufiyan Khan <81650397+CoderSufiyan@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:45:55 +0530 Subject: [PATCH 04/14] [management] fix duplicate operationId in OpenAPI spec (#6734) --- shared/management/http/api/openapi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index b38019a73..d6e2b8ba2 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -10490,7 +10490,7 @@ paths: - EDR Intune Integrations summary: Delete EDR Intune Integration description: Deletes an EDR Intune Integration by its ID. - operationId: deleteIntegration + operationId: deleteEDRIntuneIntegration responses: '200': description: Integration deleted successfully. Returns an empty object. @@ -12574,7 +12574,7 @@ paths: - Event Streaming Integrations summary: Delete Event Streaming Integration description: Deletes an event streaming integration by its ID. - operationId: deleteIntegration + operationId: deleteEventStreamingIntegration responses: '200': description: Integration deleted successfully. Returns an empty object. From aa92ad3fb18909a1ede48d9bcfeb315f47c0b2cc Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 12 Jul 2026 14:46:08 +0200 Subject: [PATCH 05/14] [management] Add agent_network_only account setting (#6736) * [management] Add agent_network_only account setting * [management] Load agent_network_only in pgx account loader and cover persistence --- .../handlers/accounts/accounts_handler.go | 4 ++ .../accounts/accounts_handler_test.go | 64 +++++++++++++++++++ management/server/store/sql_store.go | 8 ++- management/server/store/sql_store_test.go | 25 ++++++++ management/server/types/settings.go | 5 ++ shared/management/http/api/openapi.yml | 4 ++ shared/management/http/api/types.gen.go | 3 + 7 files changed, 111 insertions(+), 2 deletions(-) diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index d4342bf57..0a8b2c269 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -286,6 +286,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.MetricsPushEnabled != nil { returnSettings.MetricsPushEnabled = *req.Settings.MetricsPushEnabled } + if req.Settings.AgentNetworkOnly != nil { + returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly + } return returnSettings, nil } @@ -417,6 +420,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A AutoUpdateAlways: &settings.AutoUpdateAlways, Ipv6EnabledGroups: &settings.IPv6EnabledGroups, MetricsPushEnabled: &settings.MetricsPushEnabled, + AgentNetworkOnly: &settings.AgentNetworkOnly, EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled, LocalAuthDisabled: &settings.LocalAuthDisabled, LocalMfaEnabled: &settings.LocalMfaEnabled, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index df89fde9a..d0bcbbc3b 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -130,6 +130,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -158,6 +159,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -186,6 +188,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr("latest"), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -214,6 +217,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -242,6 +246,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), @@ -270,6 +275,65 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, + { + name: "PutAccount OK enabling agent_network_only", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(true), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, + { + name: "PutAccount OK disabling agent_network_only again", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": false},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), LocalMfaEnabled: br(false), diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 69efe65f1..c8ded4e4e 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -1605,7 +1605,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups, settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, - settings_local_mfa_enabled, settings_metrics_push_enabled, + settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1629,6 +1629,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sLazyConnectionEnabled sql.NullBool sLocalMFAEnabled sql.NullBool sMetricsPushEnabled sql.NullBool + sAgentNetworkOnly sql.NullBool sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1651,7 +1652,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups, &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, - &sLocalMFAEnabled, &sMetricsPushEnabled, + &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1720,6 +1721,9 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sMetricsPushEnabled.Valid { account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool } + if sAgentNetworkOnly.Valid { + account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool + } if sJWTAllowGroups.Valid { _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) } diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 92784af83..faef8651e 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -1245,6 +1245,31 @@ func TestSqlite_CreateAndGetObjectInTransaction(t *testing.T) { assert.NoError(t, err) } +func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.False(t, account.Settings.AgentNetworkOnly, "setting should default to false") + + account.Settings.AgentNetworkOnly = true + require.NoError(t, store.SaveAccount(context.Background(), account)) + + reloaded, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.True(t, reloaded.Settings.AgentNetworkOnly, "setting should survive a save/load round-trip") + + reloaded.Settings.AgentNetworkOnly = false + require.NoError(t, store.SaveAccount(context.Background(), reloaded)) + + disabled, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist") +} + func TestSqlStore_GetAccountUsers(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) diff --git a/management/server/types/settings.go b/management/server/types/settings.go index d17d0ef2b..7c24f944b 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -76,6 +76,10 @@ type Settings struct { // MetricsPushEnabled globally enables or disables client metrics push for the account MetricsPushEnabled bool `gorm:"default:false"` + // AgentNetworkOnly limits the dashboard to the Agent Network surface for this account. + // Set for accounts created via netbird.ai signups; users can disable it later. + AgentNetworkOnly bool `gorm:"default:false"` + // EmbeddedIdpEnabled indicates if the embedded identity provider is enabled. // This is a runtime-only field, not stored in the database. EmbeddedIdpEnabled bool `gorm:"-"` @@ -114,6 +118,7 @@ func (s *Settings) Copy() *Settings { AutoUpdateAlways: s.AutoUpdateAlways, IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups), MetricsPushEnabled: s.MetricsPushEnabled, + AgentNetworkOnly: s.AgentNetworkOnly, EmbeddedIdpEnabled: s.EmbeddedIdpEnabled, LocalAuthDisabled: s.LocalAuthDisabled, LocalMfaEnabled: s.LocalMfaEnabled, diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index d6e2b8ba2..d0c5aee8b 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -375,6 +375,10 @@ components: description: Enables or disables client metrics push for all peers in the account type: boolean example: false + agent_network_only: + description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. + type: boolean + example: false embedded_idp_enabled: description: Indicates whether the embedded identity provider (Dex) is enabled for this account. This is a read-only field. type: boolean diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 7d68a1052..6356aca18 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1647,6 +1647,9 @@ type AccountRequest struct { // AccountSettings defines model for AccountSettings. type AccountSettings struct { + // AgentNetworkOnly Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. + AgentNetworkOnly *bool `json:"agent_network_only,omitempty"` + // AutoUpdateAlways When true, updates are installed automatically in the background. When false, updates require user interaction from the UI. AutoUpdateAlways *bool `json:"auto_update_always,omitempty"` From ecd398d89501300418f8701eb9919dcbb9e911c5 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 12 Jul 2026 21:16:37 +0200 Subject: [PATCH 06/14] [management] Add dashboard_features account setting (#6742) Introduce a nullable dashboard_features object on account settings, serialized to a single JSON column so new dashboard sections can be added without schema changes. Starts with agent_network (show the Agent Network menu for an account without the deployment flag). Wires the API handler mapping, the pgx GetAccount loader, and adds store round-trip and handler tests. --- .../handlers/accounts/accounts_handler.go | 10 ++++++ .../accounts/accounts_handler_test.go | 32 +++++++++++++++++++ management/server/store/sql_store.go | 8 +++++ management/server/store/sql_store_test.go | 30 +++++++++++++++++ management/server/types/settings.go | 27 ++++++++++++++++ shared/management/http/api/openapi.yml | 10 ++++++ shared/management/http/api/types.gen.go | 9 ++++++ 7 files changed, 126 insertions(+) diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index 0a8b2c269..9fbadcbf5 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -289,6 +289,11 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.AgentNetworkOnly != nil { returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly } + if req.Settings.DashboardFeatures != nil { + returnSettings.DashboardFeatures = &types.DashboardFeatures{ + AgentNetwork: req.Settings.DashboardFeatures.AgentNetwork, + } + } return returnSettings, nil } @@ -434,6 +439,11 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A networkRangeV6Str := settings.NetworkRangeV6.String() apiSettings.NetworkRangeV6 = &networkRangeV6Str } + if settings.DashboardFeatures != nil { + apiSettings.DashboardFeatures = &api.AccountDashboardFeatures{ + AgentNetwork: settings.DashboardFeatures.AgentNetwork, + } + } apiOnboarding := api.AccountOnboarding{ OnboardingFlowPending: onboarding.OnboardingFlowPending, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index d0bcbbc3b..49a9848c0 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -312,6 +312,38 @@ func TestAccounts_AccountsHandler(t *testing.T) { expectedArray: false, expectedID: accountID, }, + { + name: "PutAccount OK setting dashboard_features agent_network", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + DashboardFeatures: &api.AccountDashboardFeatures{ + AgentNetwork: br(true), + }, + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, { name: "PutAccount OK disabling agent_network_only again", expectedBody: true, diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index c8ded4e4e..f3e24298d 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -1606,6 +1606,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, + settings_dashboard_features, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1630,6 +1631,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sLocalMFAEnabled sql.NullBool sMetricsPushEnabled sql.NullBool sAgentNetworkOnly sql.NullBool + sDashboardFeatures sql.NullString sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString @@ -1653,6 +1655,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, + &sDashboardFeatures, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1724,6 +1727,11 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sAgentNetworkOnly.Valid { account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool } + if sDashboardFeatures.Valid && sDashboardFeatures.String != "" { + if err := json.Unmarshal([]byte(sDashboardFeatures.String), &account.Settings.DashboardFeatures); err != nil { + log.WithContext(ctx).Warnf("failed to unmarshal dashboard features for account %s: %v", accountID, err) + } + } if sJWTAllowGroups.Valid { _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) } diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index faef8651e..58f62be32 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -1270,6 +1270,36 @@ func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) { require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist") } +func TestSqlStore_SaveAccountPersistsDashboardFeatures(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.Nil(t, account.Settings.DashboardFeatures, "dashboard features should default to unset") + + agentNetwork := true + account.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &agentNetwork} + require.NoError(t, store.SaveAccount(context.Background(), account)) + + reloaded, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloaded.Settings.DashboardFeatures, "dashboard features should survive a save/load round-trip") + require.NotNil(t, reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should be set") + require.True(t, *reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should persist as true") + + disabled := false + reloaded.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &disabled} + require.NoError(t, store.SaveAccount(context.Background(), reloaded)) + + reloadedDisabled, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "agent network flag should remain set") + require.False(t, *reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "explicit false should persist") +} + func TestSqlStore_GetAccountUsers(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 7c24f944b..815c93ce6 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -80,6 +80,11 @@ type Settings struct { // Set for accounts created via netbird.ai signups; users can disable it later. AgentNetworkOnly bool `gorm:"default:false"` + // DashboardFeatures holds per-account dashboard section visibility overrides. + // It serializes to a single JSON column so new sections can be added without + // a schema change. + DashboardFeatures *DashboardFeatures `gorm:"serializer:json"` + // EmbeddedIdpEnabled indicates if the embedded identity provider is enabled. // This is a runtime-only field, not stored in the database. EmbeddedIdpEnabled bool `gorm:"-"` @@ -126,9 +131,31 @@ func (s *Settings) Copy() *Settings { if s.Extra != nil { settings.Extra = s.Extra.Copy() } + if s.DashboardFeatures != nil { + settings.DashboardFeatures = s.DashboardFeatures.Copy() + } return settings } +// DashboardFeatures holds per-account dashboard section visibility overrides. +// Nil fields are unset and follow the default dashboard behavior; an explicit +// value forces that section shown or hidden for the account. +type DashboardFeatures struct { + // AgentNetwork, when set, forces the Agent Network menu shown (true) or + // hidden (false) regardless of the deployment feature flag. + AgentNetwork *bool `json:"agent_network,omitempty"` +} + +// Copy returns a deep copy of the DashboardFeatures struct. +func (d *DashboardFeatures) Copy() *DashboardFeatures { + c := &DashboardFeatures{} + if d.AgentNetwork != nil { + v := *d.AgentNetwork + c.AgentNetwork = &v + } + return c +} + type ExtraSettings struct { // PeerApprovalEnabled enables or disables the need for peers bo be approved by an administrator PeerApprovalEnabled bool diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index d0c5aee8b..c61dbd2f8 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -379,6 +379,8 @@ components: description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. type: boolean example: false + dashboard_features: + $ref: '#/components/schemas/AccountDashboardFeatures' embedded_idp_enabled: description: Indicates whether the embedded identity provider (Dex) is enabled for this account. This is a read-only field. type: boolean @@ -407,6 +409,14 @@ components: - regular_users_view_blocked - peer_expose_enabled - peer_expose_groups + AccountDashboardFeatures: + description: Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. + type: object + properties: + agent_network: + description: Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + type: boolean + example: true AccountExtraSettings: type: object properties: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 6356aca18..6fc17ef60 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1612,6 +1612,12 @@ type Account struct { Settings AccountSettings `json:"settings"` } +// AccountDashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. +type AccountDashboardFeatures struct { + // AgentNetwork Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + AgentNetwork *bool `json:"agent_network,omitempty"` +} + // AccountExtraSettings defines model for AccountExtraSettings. type AccountExtraSettings struct { // NetworkTrafficLogsEnabled Enables or disables network traffic logging. If enabled, all network traffic events from peers will be stored. @@ -1656,6 +1662,9 @@ type AccountSettings struct { // AutoUpdateVersion Set Clients auto-update version. "latest", "disabled", or a specific version (e.g "0.50.1") AutoUpdateVersion *string `json:"auto_update_version,omitempty"` + // DashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. + DashboardFeatures *AccountDashboardFeatures `json:"dashboard_features,omitempty"` + // DnsDomain Allows to define a custom dns domain for the account DnsDomain *string `json:"dns_domain,omitempty"` From 76877e83c47eadaeddf314ae794168bcf0cbb4bf Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 13 Jul 2026 13:32:47 +0200 Subject: [PATCH 07/14] [client] Bring the connection up in Go after SSO login (#6744) * [client] Bring the connection up in Go after SSO login The post-login Up ran as a frontend promise continuation after WaitSSOLogin resolved. During SSO the tray window is hidden and the webview is suspended (macOS App Nap / hidden-window timer throttling), so that continuation didn't run until the user woke the window (e.g. hovering the tray icon), leaving the client not connected for a long time. Combine WaitSSOLogin and Up in a single Go method so the daemon connects the moment SSO completes, independent of webview state. The frontend no longer issues a separate Up on the SSO path. * [client] unexport waitSSOLogin and move below exported methods --- client/ui/frontend/src/lib/connection.ts | 21 ++++++--- client/ui/services/connection.go | 60 +++++++++++++++++------- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/client/ui/frontend/src/lib/connection.ts b/client/ui/frontend/src/lib/connection.ts index b9e98bf24..fca03fc87 100644 --- a/client/ui/frontend/src/lib/connection.ts +++ b/client/ui/frontend/src/lib/connection.ts @@ -51,7 +51,14 @@ async function runSsoLogin( if (uri) await openBrowserLoginUri(uri); const cancelPromise = buildSsoCancelPromise(state, signal); - const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" }); + // Combine wait + up in Go so the connection comes up the moment SSO + // completes. During SSO the tray window is hidden and the webview is + // suspended, so a frontend-driven Up (a promise continuation) would not + // fire until the user woke the window (e.g. hovering the tray icon). + const waitPromise = Connection.WaitSSOLoginAndUp( + { userCode: result.userCode, hostname: "" }, + { profileName: "", username: "" }, + ); try { await Promise.race([waitPromise, cancelPromise]); @@ -89,13 +96,13 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign if (signal?.aborted) state.cancelled = true; if (!state.cancelled && result.needsSsoLogin) { + // runSsoLogin brings the connection up in Go once SSO completes. await runSsoLogin(result, state, signal); - } - - if (!state.cancelled && signal?.aborted) state.cancelled = true; - - if (!state.cancelled) { - await Connection.Up({ profileName: "", username: "" }); + } else { + if (!state.cancelled && signal?.aborted) state.cancelled = true; + if (!state.cancelled) { + await Connection.Up({ profileName: "", username: "" }); + } } } catch (e) { WindowManager.CloseBrowserLogin().catch(console.error); diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index 8e7919af6..fae7ddd23 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -35,7 +35,7 @@ type LoginResult struct { VerificationURIComplete string `json:"verificationUriComplete"` } -// WaitSSOParams are the inputs to WaitSSOLogin. +// WaitSSOParams are the inputs to waitSSOLogin. type WaitSSOParams struct { UserCode string `json:"userCode"` Hostname string `json:"hostname"` @@ -125,23 +125,6 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err }, nil } -func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) { - cli, err := s.conn.Client() - if err != nil { - return "", err - } - log.Infof("waiting for SSO login to complete") - resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{ - UserCode: p.UserCode, - Hostname: p.Hostname, - }) - if err != nil { - return "", s.classifyDaemonError(err) - } - log.Infof("SSO login completed, daemon reported success") - return resp.GetEmail(), nil -} - func (s *Connection) Up(ctx context.Context, p UpParams) error { cli, err := s.conn.Client() if err != nil { @@ -162,6 +145,27 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error { return nil } +// WaitSSOLoginAndUp blocks until the SSO login completes and then brings the +// connection up, both from the Go side. Keeping the post-login Up here rather +// than as a frontend continuation is deliberate: during SSO the tray window is +// hidden and the webview is suspended (macOS App Nap / hidden-window timer +// throttling), so a frontend-driven Up would not run until the user woke the +// window (e.g. by hovering the tray icon). Doing it in Go connects the moment +// the daemon reports SSO success. Returns the authenticated user's email. +func (s *Connection) WaitSSOLoginAndUp(ctx context.Context, wait WaitSSOParams, up UpParams) (string, error) { + email, err := s.waitSSOLogin(ctx, wait) + if err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + if err := s.Up(ctx, up); err != nil { + return "", err + } + return email, nil +} + func (s *Connection) Down(ctx context.Context) error { cli, err := s.conn.Client() if err != nil { @@ -221,6 +225,26 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { return nil } +// waitSSOLogin blocks until the daemon reports the SSO login result and returns +// the authenticated user's email. It is unexported because the frontend drives +// SSO through the exported WaitSSOLoginAndUp. +func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + log.Infof("waiting for SSO login to complete") + resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{ + UserCode: p.UserCode, + Hostname: p.Hostname, + }) + if err != nil { + return "", s.classifyDaemonError(err) + } + log.Infof("SSO login completed, daemon reported success") + return resp.GetEmail(), nil +} + // classifyDaemonError maps a gRPC error to a localised ClientError. func (s *Connection) classifyDaemonError(err error) *ClientError { return s.classifier.classify(err) From 8f64173574eebc51198ec1c805095b01c3259dba Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 13 Jul 2026 13:39:57 +0200 Subject: [PATCH 08/14] [client] Enable launch-on-login by default on fresh GUI installs (#6738) * [client] Add autostart preference marker and MDM disableAutostart key Adds the autostartInitialized marker to the Wails UI preferences store so the one-time autostart default decision can persist per OS user, and a UI-only disableAutostart MDM policy key that suppresses the default and flows into GetConfigResponse.mDMManagedFields like disableAutoConnect. * [client] Enable launch-on-login by default on fresh GUI installs On the first interactive run the GUI persists the autostartInitialized marker before any enable attempt, then enables autostart only when the platform supports it, MDM policy does not disable it, the process was not relaunched by an installer/updater (--post-update), and the installer's fresh-install breadcrumb is present. Upgrading users have no breadcrumb, so an update can never write login items, and a user's disable in Settings is never overridden. * [release] Write fresh-install breadcrumb from installers Installers write a .fresh-install breadcrumb on fresh installs only and delete stale breadcrumbs on upgrade; none of them writes login items or registry Run keys. Windows NSIS detects upgrades via the uninstall registry entry or an existing installed executable; the macOS pkg via the previous pkgutil receipt; Linux deb/rpm via the standard postinstall arguments. Post-update GUI relaunches (macOS open, Linux ui-post-install.sh) pass --post-update so the first-run autostart default cannot fire on updates. * Revert installer breadcrumb changes The real Windows installer does uninstall-then-install and deletes $INSTDIR, so a breadcrumb written there cannot survive or discriminate a fresh install from an upgrade. Restore the three installer files to their main versions; no installer or updater writes an autostart entry. * Detect fresh install from NetBird footprint instead of installer breadcrumb Replace the installer-written breadcrumb discriminator with a GUI-side check. netbirdFootprintExists inspects the daemon config/state files (default.json, legacy config.json, state.json) under profilemanager's default config dir; combined with whether the UI preferences file already existed, this tells a genuinely fresh machine from an existing or upgrading user. Only the signed GUI, via Wails, ever enables launch-on-login, and a user's later manual disable is never overridden. The preferences store now exposes ExistedAtLoad and the --post-update flag is dropped. * Update tests for footprint-based autostart default Table tests for shouldEnableAutostartDefault now cover supported, mdmDisabled, and priorInstall guards plus precedence; breadcrumb and post-update cases are removed. Add a store test asserting ExistedAtLoad is false with no file and true after persisting and reopening. --- client/mdm/canonical_loaders.go | 1 + client/mdm/policy.go | 22 +++-- client/ui/autostart_default.go | 107 ++++++++++++++++++++++++ client/ui/autostart_default_test.go | 125 ++++++++++++++++++++++++++++ client/ui/main.go | 3 + client/ui/preferences/store.go | 50 ++++++++++- client/ui/preferences/store_test.go | 40 +++++++++ client/ui/services/settings.go | 3 +- 8 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 client/ui/autostart_default.go create mode 100644 client/ui/autostart_default_test.go diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index cb9af9ccb..29288b511 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -22,6 +22,7 @@ var allKeys = []string{ KeyDisableMetricsCollection, KeyAllowServerSSH, KeyDisableAutoConnect, + KeyDisableAutostart, KeyPreSharedKey, KeyRosenpassEnabled, KeyRosenpassPermissive, diff --git a/client/mdm/policy.go b/client/mdm/policy.go index b76c70a75..1feff28f8 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -20,10 +20,10 @@ import ( // names (lowerCamelCase) so the daemon can map a Policy key directly to a // configuration field. const ( - KeyManagementURL = "managementURL" - KeyDisableUpdateSettings = "disableUpdateSettings" - KeyDisableProfiles = "disableProfiles" - KeyDisableNetworks = "disableNetworks" + KeyManagementURL = "managementURL" + KeyDisableUpdateSettings = "disableUpdateSettings" + KeyDisableProfiles = "disableProfiles" + KeyDisableNetworks = "disableNetworks" // KeyDisableAdvancedView gates the advanced-view section in the // upcoming UI revision. UI-only: NOT stored on Config, not // applied by applyMDMPolicy, not rejectable via SetConfig. The @@ -37,10 +37,16 @@ const ( KeyDisableMetricsCollection = "disableMetricsCollection" KeyAllowServerSSH = "allowServerSSH" KeyDisableAutoConnect = "disableAutoConnect" - KeyPreSharedKey = "preSharedKey" - KeyRosenpassEnabled = "rosenpassEnabled" - KeyRosenpassPermissive = "rosenpassPermissive" - KeyWireguardPort = "wireguardPort" + // KeyDisableAutostart suppresses the GUI's fresh-install + // launch-on-login default and marks the Settings toggle as + // MDM-managed. UI-only: NOT stored on Config and not applied by + // applyMDMPolicy; the GUI reads it directly and it appears in + // GetConfigResponse.mDMManagedFields when set. + KeyDisableAutostart = "disableAutostart" + KeyPreSharedKey = "preSharedKey" + KeyRosenpassEnabled = "rosenpassEnabled" + KeyRosenpassPermissive = "rosenpassPermissive" + KeyWireguardPort = "wireguardPort" // Split tunnel is modeled as a single conceptual policy with two // registry/plist values. KeySplitTunnelMode is the discriminator diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go new file mode 100644 index 000000000..bf1b16a97 --- /dev/null +++ b/client/ui/autostart_default.go @@ -0,0 +1,107 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" +) + +// autostartDefaultState carries the guard inputs of the one-time autostart +// default decision so the decision itself stays a pure, testable function. +type autostartDefaultState struct { + supported bool + mdmDisabled bool + priorInstall bool +} + +// shouldEnableAutostartDefault applies the first-run guards in order and +// returns whether autostart may be enabled, plus the reason when it may not. +func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) { + switch { + case !s.supported: + return false, "autostart not supported on this platform" + case s.mdmDisabled: + return false, "autostart disabled by MDM policy" + case s.priorInstall: + return false, "existing NetBird installation" + } + return true, "" +} + +// autostartDisabledByMDM reports whether the MDM policy manages the +// disableAutostart key in a way that must suppress the default. An +// unparseable managed value is treated as disabled to stay on the safe side. +func autostartDisabledByMDM(policy *mdm.Policy) bool { + if !policy.HasKey(mdm.KeyDisableAutostart) { + return false + } + disabled, ok := policy.GetBool(mdm.KeyDisableAutostart) + return !ok || disabled +} + +// netbirdFootprintExists reports whether the machine already carries NetBird +// daemon config or state, meaning this is not a genuinely fresh install. It is +// the update-safety gate for the autostart default: upgrading users always +// have a footprint, so an update can never trigger a login-item write. +func netbirdFootprintExists() bool { + candidates := []string{ + profilemanager.DefaultConfigPath, + filepath.Join(profilemanager.DefaultConfigPathDir, "config.json"), + filepath.Join(profilemanager.DefaultConfigPathDir, "state.json"), + } + for _, path := range candidates { + if path != "" && fileExists(path) { + return true + } + } + return false +} + +// applyAutostartDefault runs the one-time launch-on-login default for genuinely +// fresh installs. The autostartInitialized marker is persisted before any +// enable attempt so a crash mid-flow degrades to "never enabled" instead of +// retrying login-item 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) { + priorFootprint := netbirdFootprintExists() || prefsFileExisted + + if prefs.Get().AutostartInitialized { + return + } + if err := prefs.SetAutostartInitialized(true); err != nil { + log.Warnf("persist autostart marker, skipping autostart default: %v", err) + return + } + + state := autostartDefaultState{ + supported: autostart.Supported(ctx), + mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()), + priorInstall: priorFootprint, + } + enable, reason := shouldEnableAutostartDefault(state) + if !enable { + log.Debugf("skipping autostart default: %s", reason) + return + } + + if err := autostart.SetEnabled(ctx, true); err != nil { + log.Warnf("enable autostart on fresh install: %v", err) + return + } + log.Info("autostart enabled by default on fresh install") +} + +// fileExists reports whether path exists. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/client/ui/autostart_default_test.go b/client/ui/autostart_default_test.go new file mode 100644 index 000000000..b7bdf9f2a --- /dev/null +++ b/client/ui/autostart_default_test.go @@ -0,0 +1,125 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/mdm" +) + +func TestShouldEnableAutostartDefault(t *testing.T) { + allPass := autostartDefaultState{ + supported: true, + mdmDisabled: false, + priorInstall: false, + } + + tests := []struct { + name string + mutate func(*autostartDefaultState) + wantEnable bool + wantReason string + }{ + { + name: "fresh install with all guards passing enables", + mutate: func(*autostartDefaultState) {}, + wantEnable: true, + }, + { + name: "unsupported platform skips", + mutate: func(s *autostartDefaultState) { s.supported = false }, + wantReason: "autostart not supported on this platform", + }, + { + name: "MDM disable skips", + mutate: func(s *autostartDefaultState) { s.mdmDisabled = true }, + wantReason: "autostart disabled by MDM policy", + }, + { + name: "existing installation (upgrade) skips", + mutate: func(s *autostartDefaultState) { s.priorInstall = true }, + wantReason: "existing NetBird installation", + }, + { + name: "unsupported wins over every other guard", + mutate: func(s *autostartDefaultState) { + s.supported = false + s.mdmDisabled = true + s.priorInstall = true + }, + wantReason: "autostart not supported on this platform", + }, + { + name: "MDM disable wins over prior install", + mutate: func(s *autostartDefaultState) { + s.mdmDisabled = true + s.priorInstall = true + }, + wantReason: "autostart disabled by MDM policy", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + state := allPass + tc.mutate(&state) + enable, reason := shouldEnableAutostartDefault(state) + assert.Equal(t, tc.wantEnable, enable, "enable decision should match for state %+v", state) + assert.Equal(t, tc.wantReason, reason, "skip reason should identify the failing guard") + }) + } +} + +func TestAutostartDisabledByMDM(t *testing.T) { + tests := []struct { + name string + values map[string]any + want bool + }{ + { + name: "empty policy does not disable", + values: nil, + want: false, + }, + { + name: "unrelated managed keys do not disable", + values: map[string]any{mdm.KeyDisableAutoConnect: true}, + want: false, + }, + { + name: "disableAutostart true disables", + values: map[string]any{mdm.KeyDisableAutostart: true}, + want: true, + }, + { + name: "disableAutostart registry DWORD 1 disables", + values: map[string]any{mdm.KeyDisableAutostart: int64(1)}, + want: true, + }, + { + name: "disableAutostart string true disables", + values: map[string]any{mdm.KeyDisableAutostart: "true"}, + want: true, + }, + { + name: "disableAutostart explicit false allows", + values: map[string]any{mdm.KeyDisableAutostart: false}, + want: false, + }, + { + name: "unparseable managed value is treated as disabled", + values: map[string]any{mdm.KeyDisableAutostart: "not-a-bool"}, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := autostartDisabledByMDM(mdm.NewPolicy(tc.values)) + assert.Equal(t, tc.want, got, "MDM disable decision should match for values %v", tc.values) + }) + } +} diff --git a/client/ui/main.go b/client/ui/main.go index e6b77762c..4889bad79 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -197,6 +197,9 @@ func main() { // daemon may keep the main window from showing, so the OS toast is the // only reliable signal the user gets. go notifyIfDaemonOutdated(compat, notifier, localizer) + // One-time launch-on-login default for fresh installs; gated by the + // NetBird footprint check, MDM policy, and the persisted marker. + go applyAutostartDefault(context.Background(), services.NewAutostart(app.Autostart), prefStore, prefStore.ExistedAtLoad()) }) if err := app.Run(); err != nil { diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go index afc854185..df6fbbb16 100644 --- a/client/ui/preferences/store.go +++ b/client/ui/preferences/store.go @@ -54,6 +54,10 @@ type UIPreferences struct { Language i18n.LanguageCode `json:"language"` ViewMode ViewMode `json:"viewMode"` OnboardingCompleted bool `json:"onboardingCompleted"` + // AutostartInitialized records that the one-time autostart default + // decision has run for this OS user. It only ever transitions to true + // and is never reset, so the default-on flow runs at most once, ever. + AutostartInitialized bool `json:"autostartInitialized"` } // LanguageValidator rejects SetLanguage inputs with no shipped bundle. @@ -72,8 +76,9 @@ type Emitter interface { type Store struct { path string - mu sync.RWMutex - current UIPreferences + mu sync.RWMutex + current UIPreferences + existedAtLoad bool subsMu sync.Mutex subs []chan UIPreferences @@ -157,6 +162,27 @@ func (s *Store) SetOnboardingCompleted(done bool) error { return nil } +// SetAutostartInitialized persists the one-time autostart decision marker. +// No-op if unchanged. +func (s *Store) SetAutostartInitialized(done bool) error { + s.mu.Lock() + if s.current.AutostartInitialized == done { + s.mu.Unlock() + return nil + } + next := s.current + next.AutostartInitialized = done + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + // SetLanguage validates, persists, and broadcasts. No-op if unchanged. func (s *Store) SetLanguage(lang i18n.LanguageCode) error { if lang == "" { @@ -206,13 +232,29 @@ func (s *Store) Subscribe() (<-chan UIPreferences, func()) { return ch, unsubscribe } +// ExistedAtLoad reports whether the backing preferences file was present on +// disk when the store loaded. It distinguishes a user who ran a prior GUI +// version from a brand-new OS user with no preferences yet. +func (s *Store) ExistedAtLoad() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.existedAtLoad +} + // load reads the file into current. A missing file is not an error (the // in-memory default stands); malformed contents return an error. func (s *Store) load() error { - if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) { - return nil + if _, err := os.Stat(s.path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("stat preferences: %w", err) } + s.mu.Lock() + s.existedAtLoad = true + s.mu.Unlock() + var loaded UIPreferences if _, err := util.ReadJson(s.path, &loaded); err != nil { return err diff --git a/client/ui/preferences/store_test.go b/client/ui/preferences/store_test.go index 0d1cc7b54..6384fddb8 100644 --- a/client/ui/preferences/store_test.go +++ b/client/ui/preferences/store_test.go @@ -215,6 +215,46 @@ func TestStore_FileShapeIsJSON(t *testing.T) { assert.Equal(t, i18n.LanguageCode("hu"), parsed.Language) } +func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(nil, emitter) + require.NoError(t, err) + + assert.False(t, s.Get().AutostartInitialized, "marker must default to false when no file is on disk") + + require.NoError(t, s.SetAutostartInitialized(true)) + assert.True(t, s.Get().AutostartInitialized, "Get should reflect the persisted marker") + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first marker write should broadcast") + + // Re-setting the same value must be a no-op: no disk write, no broadcast. + require.NoError(t, s.SetAutostartInitialized(true)) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent marker write should not broadcast again") + + // A fresh Store (new GUI launch) must see the marker so the autostart + // default decision never runs twice. + reloaded, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk") +} + +func TestStore_ExistedAtLoad(t *testing.T) { + withTempConfigDir(t) + + // Brand-new OS user: no preferences file on disk yet. + fresh, err := NewStore(nil, nil) + require.NoError(t, err) + assert.False(t, fresh.ExistedAtLoad(), "ExistedAtLoad must be false when no file is on disk") + + // Persisting a value writes the file to disk. + require.NoError(t, fresh.SetLanguage("en")) + + // A subsequent GUI launch reopens the now-present file. + reopened, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reopened.ExistedAtLoad(), "ExistedAtLoad must be true after the store has persisted and is reopened") +} + func TestStore_ErrUnsupportedSentinel(t *testing.T) { // Verifies callers can match on the sentinel error rather than parsing // strings — protects against accidental %v -> %w changes that would diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 1c16795ae..3b6f6f81b 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -20,11 +20,12 @@ type MDMFields struct { 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"` + DisableAdvancedView bool `json:"disableAdvancedView"` } type Features struct { From 831325d6e28ab96be5bf1e423b8a33227e41b344 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 13 Jul 2026 17:28:19 +0200 Subject: [PATCH 09/14] [management] require dashboard_features.agent_network when enabling agent_network_only (#6750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a settings constraint: enabling `agent_network_only` requires `dashboard_features.agent_network` to be `true` in the same account update. Without the Agent Network menu flag, a focused account that later turns the focused view off would lose access to the Agent Network menu entirely, so the two must be set together. The check runs in `updateAccountRequestSettings` against the parsed request state: if the resulting settings have `agent_network_only == true` but `dashboard_features.agent_network` is not `true`, the update is rejected with `status.InvalidArgument` (HTTP 422) before anything is persisted. The OpenAPI field descriptions for `agent_network_only` and `dashboard_features.agent_network` document the requirement. Only the descriptions changed — `required` and the schema `$ref` are untouched — and `types.gen.go` was regenerated from the spec (diff is the two comment lines). --- .../handlers/accounts/accounts_handler.go | 7 +++++++ .../accounts/accounts_handler_test.go | 20 +++++++++++++++---- shared/management/http/api/openapi.yml | 4 ++-- shared/management/http/api/types.gen.go | 4 ++-- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index 9fbadcbf5..c4cba5962 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -295,6 +295,13 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS } } + if returnSettings.AgentNetworkOnly && + (returnSettings.DashboardFeatures == nil || + returnSettings.DashboardFeatures.AgentNetwork == nil || + !*returnSettings.DashboardFeatures.AgentNetwork) { + return nil, status.Errorf(status.InvalidArgument, "agent network only mode requires dashboard_features.agent_network to be enabled") + } + return returnSettings, nil } diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index 49a9848c0..0069efcb7 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -288,7 +288,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { expectedBody: true, requestType: http.MethodPut, requestPath: "/api/accounts/" + accountID, - requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), expectedStatus: http.StatusOK, expectedSettings: api.AccountSettings{ PeerLoginExpiration: 15552000, @@ -305,13 +305,25 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), AgentNetworkOnly: br(true), - EmbeddedIdpEnabled: br(false), - LocalAuthDisabled: br(false), - LocalMfaEnabled: br(false), + DashboardFeatures: &api.AccountDashboardFeatures{ + AgentNetwork: br(true), + }, + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, }, + { + name: "PutAccount fails enabling agent_network_only without dashboard_features", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusUnprocessableEntity, + expectedArray: false, + }, { name: "PutAccount OK setting dashboard_features agent_network", expectedBody: true, diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index c61dbd2f8..529cd2225 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -376,7 +376,7 @@ components: type: boolean example: false agent_network_only: - description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. + description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. Enabling this requires dashboard_features.agent_network to be true in the same request. type: boolean example: false dashboard_features: @@ -414,7 +414,7 @@ components: type: object properties: agent_network: - description: Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + description: Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. Must be true when agent_network_only is enabled. type: boolean example: true AccountExtraSettings: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 6fc17ef60..4956f9a9b 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1614,7 +1614,7 @@ type Account struct { // AccountDashboardFeatures Per-account dashboard section visibility overrides. Omitted keys follow the default dashboard behavior. type AccountDashboardFeatures struct { - // AgentNetwork Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. + // AgentNetwork Controls the Agent Network menu for the account regardless of the deployment feature flag. When true the menu is shown, when false it is hidden, and when omitted the default behavior applies. Must be true when agent_network_only is enabled. AgentNetwork *bool `json:"agent_network,omitempty"` } @@ -1653,7 +1653,7 @@ type AccountRequest struct { // AccountSettings defines model for AccountSettings. type AccountSettings struct { - // AgentNetworkOnly Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. + // AgentNetworkOnly Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. Enabling this requires dashboard_features.agent_network to be true in the same request. AgentNetworkOnly *bool `json:"agent_network_only,omitempty"` // AutoUpdateAlways When true, updates are installed automatically in the background. When false, updates require user interaction from the UI. From cc64a93953d17ae3161f36da8030498af37d143d Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:49:17 +0900 Subject: [PATCH 10/14] [client] Include system events in ToProtoFullStatus conversion (#6746) --- client/internal/debug/debug.go | 1 - client/status/status.go | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 3a7c0ebff..0e506ccd7 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -480,7 +480,6 @@ func (g *BundleGenerator) addStatus() error { fullStatus := g.statusRecorder.GetFullStatus() protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus) - protoFullStatus.Events = g.statusRecorder.GetEventHistory() overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{ Anonymize: g.anonymize, ProfileName: profName, diff --git a/client/status/status.go b/client/status/status.go index a53585c99..e8276d0fa 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -746,6 +746,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState) } + pbFullStatus.Events = fullStatus.Events + return &pbFullStatus } From 62703ca23e97073869bb723b64a0f738c465d93b Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:06:22 +0200 Subject: [PATCH 11/14] [management] add logs to ephemeral delete (#6747) --- .../modules/peers/ephemeral/manager/ephemeral.go | 2 +- management/internals/modules/peers/manager.go | 2 ++ management/internals/shared/grpc/token_mgr.go | 4 ++++ management/server/peer.go | 8 ++++++-- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/management/internals/modules/peers/ephemeral/manager/ephemeral.go b/management/internals/modules/peers/ephemeral/manager/ephemeral.go index 0f902ea70..7867d6579 100644 --- a/management/internals/modules/peers/ephemeral/manager/ephemeral.go +++ b/management/internals/modules/peers/ephemeral/manager/ephemeral.go @@ -215,7 +215,7 @@ func (e *EphemeralManager) cleanup(ctx context.Context) { } for accountID, peerIDs := range peerIDsPerAccount { - log.WithContext(ctx).Tracef("cleanup: deleting %d ephemeral peers for account %s", len(peerIDs), accountID) + log.WithContext(ctx).Debugf("cleanup: deleting %d ephemeral peers for account %s: %s", len(peerIDs), accountID, peerIDs) err := e.peersManager.DeletePeers(ctx, accountID, peerIDs, activity.SystemInitiator, true) if err != nil { log.WithContext(ctx).Errorf("failed to delete ephemeral peers: %s", err) diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 239d6b09c..5e4538d08 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -184,6 +184,8 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs return err } + log.WithContext(ctx).Debugf("DeletePeers: deleted peer %s", peerID) + if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") { eventsToStore = append(eventsToStore, func() { m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain)) diff --git a/management/internals/shared/grpc/token_mgr.go b/management/internals/shared/grpc/token_mgr.go index 65e58ad41..fb2d83a9a 100644 --- a/management/internals/shared/grpc/token_mgr.go +++ b/management/internals/shared/grpc/token_mgr.go @@ -161,6 +161,8 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI m.turnCancelMap[peerID] = turnCancel go m.refreshTURNTokens(ctx, accountID, peerID, turnCancel) log.WithContext(ctx).Debugf("starting TURN refresh for %s", peerID) + } else { + log.WithContext(ctx).Debugf("no TURN configuration, skipping TURN refresh for %s", peerID) } if m.relayCfg != nil { @@ -168,6 +170,8 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI m.relayCancelMap[peerID] = relayCancel go m.refreshRelayTokens(ctx, accountID, peerID, relayCancel) log.WithContext(ctx).Tracef("starting relay refresh for %s", peerID) + } else { + log.WithContext(ctx).Tracef("no relay configuration, skipping relay refresh for %s", peerID) } } diff --git a/management/server/peer.go b/management/server/peer.go index 32bf9feea..5f2f5d2a2 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -106,11 +106,13 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK } if !updated { am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusStale) - log.WithContext(ctx).Tracef("peer %s already has a newer session in store, skipping connect", peer.ID) + log.WithContext(ctx).Debugf("peer %s already has a newer session in store, skipping connect", peer.ID) return nil } am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusApplied) + log.WithContext(ctx).Debugf("mark peer %s connected", peer.ID) + if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil { return err } @@ -180,12 +182,14 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP } if !updated { am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusStale) - log.WithContext(ctx).Tracef("peer %s session token mismatch on disconnect (token=%d), skipping", + log.WithContext(ctx).Debugf("peer %s session token mismatch on disconnect (token=%d), skipping", peer.ID, sessionStartedAt) return nil } am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied) + log.WithContext(ctx).Debugf("mark peer %s disconnected", peer.ID) + // Symmetric with MarkPeerConnected: when an embedded proxy peer goes // offline, refresh the peers that had synthesized records pointing at // it so they pull the stale entries instead of waiting out TTL. From 5343402385bf858bb6d8e85f4ad806444739f697 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:46:03 +0200 Subject: [PATCH 12/14] [client, relay] Increase early-message buffer cap to 10000 to avoid dropping relayed handshakes --- shared/relay/client/early_msg_buffer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/relay/client/early_msg_buffer.go b/shared/relay/client/early_msg_buffer.go index 52ff4d42e..d74009d10 100644 --- a/shared/relay/client/early_msg_buffer.go +++ b/shared/relay/client/early_msg_buffer.go @@ -10,7 +10,7 @@ import ( const ( earlyMsgTTL = 5 * time.Second - earlyMsgCapacity = 1000 + earlyMsgCapacity = 10000 ) // earlyMsgBuffer buffers transport messages that arrive before the corresponding From 39193396f5ebf39e405f89f9b11c50ddceb51d12 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:21:59 +0200 Subject: [PATCH 13/14] [client] Fix WGWatcher silently failing to restart on fast disconnect/reconnect (#6664) * Stick new watcher creation to actual existence of af the conn and its removal to the removal of such same conn. Avoid debouncing and cross lock dead locking * Discriminate not updated from timeout handshakes * [Recheck watcher ctx cancellation under conn.mu in onWGDisconnected onWGDisconnected only checked conn.ctx (the engine-scoped context), never the watcher's own context. disableWgWatcherIfNeeded cancels the wgWatcherCtx, not conn.ctx, so a disabled watcher's timeout callback did not see the cancellation. handshakeCheck runs lock-free, so between the ctx check in periodicHandshakeCheck and acquiring conn.mu a fast disconnect/reconnect can slip in: the stale watcher then acquires the lock and tears down the *new*, healthy connection based on the old timeout, forcing the guard into an unnecessary reconnect (flap). Recheck watcherCtx.Err() under conn.mu so a superseded watcher exits without touching the connection that replaced it. * Remove verbose comments * Fixup merge conflict leftovers * Fixup context brought by onWGDisconnected --- client/internal/peer/conn.go | 34 +++++++++++++++++------- client/internal/peer/conn_test.go | 14 +++++----- client/internal/peer/wg_watcher.go | 35 +++++++------------------ client/internal/peer/wg_watcher_test.go | 12 +++------ 4 files changed, 46 insertions(+), 49 deletions(-) diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index f0625c853..09a4e8b02 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -203,7 +203,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { statusICE: worker.NewAtomicStatus(), dumpState: dumpState, endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)), - wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState), metricsRecorder: services.MetricsRecorder, } @@ -671,11 +670,12 @@ func (conn *Conn) onGuardEvent() { } } -func (conn *Conn) onWGDisconnected() { +func (conn *Conn) onWGDisconnected(watcherCtx context.Context) { conn.mu.Lock() defer conn.mu.Unlock() - if conn.ctx.Err() != nil { + // watcherCtx guards against a stale watcher tearing down a connection that already superseded it. + if conn.ctx.Err() != nil || watcherCtx.Err() != nil { return } @@ -833,25 +833,39 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { }) } +// enableWgWatcherIfNeeded starts a fresh watcher instance per connection attempt, so its +// lifecycle stays bound to conn.mu and enable/disable can't race an old goroutine's shutdown. +// Caller must hold conn.mu. func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) { - if !conn.wgWatcher.PrepareInitialHandshake() { + if conn.wgWatcher != nil { return } + watcher := NewWGWatcher(conn.Log, conn.config.WgConfig.WgInterface, conn.config.Key, conn.dumpState) + watcher.PrepareInitialHandshake() + wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx) + conn.wgWatcher = watcher conn.wgWatcherCancel = wgWatcherCancel + conn.wgWatcherWg.Add(1) go func() { defer conn.wgWatcherWg.Done() - conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess) + onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) } + watcher.EnableWgWatcher(wgWatcherCtx, enabledTime, onDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess) }() } +// disableWgWatcherIfNeeded cancels and drops the watcher once no transport is active. It never +// waits for the goroutine: the timeout path reentrantly calls back here under conn.mu, so +// blocking would deadlock. Caller must hold conn.mu. func (conn *Conn) disableWgWatcherIfNeeded() { - if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil { - conn.wgWatcherCancel() - conn.wgWatcherCancel = nil + if conn.currentConnPriority != conntype.None || conn.wgWatcher == nil { + return } + conn.wgWatcherCancel() + conn.wgWatcher = nil + conn.wgWatcherCancel = nil } func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { @@ -874,7 +888,9 @@ func (conn *Conn) resetEndpoint() { return } conn.Log.Infof("reset wg endpoint") - conn.wgWatcher.Reset() + if conn.wgWatcher != nil { + conn.wgWatcher.Reset() + } if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil { conn.Log.Warnf("failed to remove endpoint address before update: %v", err) } diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index f2312a66a..49979ea83 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -339,20 +339,20 @@ func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) { conn := newWGTimeoutTestConn(true, &disconnected) for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } assert.Empty(t, disconnected, "escalation must not fire below the threshold") - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected, "reaching the threshold must report the peer disconnected once") for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } assert.Len(t, disconnected, 1, "escalation must restart counting after firing") - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) assert.Len(t, disconnected, 2, "continued timeouts must escalate again") } @@ -364,12 +364,12 @@ func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) { conn := newWGTimeoutTestConn(true, &disconnected) for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } conn.onWGCheckSuccess() for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } assert.Empty(t, disconnected, "handshake success must reset the timeout count") } @@ -382,7 +382,7 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) { conn := newWGTimeoutTestConn(false, &disconnected) for i := 0; i < wgTimeoutEscalationThreshold*3; i++ { - conn.onWGDisconnected() + conn.onWGDisconnected(conn.ctx) } assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections") } diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index 10c22153f..39e3d3264 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -3,7 +3,6 @@ package peer import ( "context" "fmt" - "sync" "time" log "github.com/sirupsen/logrus" @@ -24,14 +23,14 @@ type WGInterfaceStater interface { GetStats() (map[string]configurer.WGStats, error) } +// WGWatcher is single-shot: one instance per connection attempt, run once, then discarded. +// Lifecycle is owned by Conn under conn.mu, so it keeps no "enabled" state to go stale. type WGWatcher struct { log *log.Entry wgIfaceStater WGInterfaceStater peerKey string stateDump *stateDump - enabled bool - muEnabled sync.Mutex // initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently. initialHandshake time.Time @@ -48,25 +47,14 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin } } -// PrepareInitialHandshake reserves the watcher and reads the peer's current WireGuard -// handshake time. It must be called before the peer is (re)configured on the WireGuard -// interface, so the captured baseline reflects the state prior to this connection attempt -// instead of racing with that configuration. Returns ok=false if the watcher is already -// running, in which case EnableWgWatcher must not be called. -func (w *WGWatcher) PrepareInitialHandshake() (ok bool) { - w.muEnabled.Lock() - if w.enabled { - w.muEnabled.Unlock() - return false - } - +// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be +// called before the peer is (re)configured on the WireGuard interface, so the captured +// baseline reflects the state prior to this connection attempt instead of racing with +// that configuration. +func (w *WGWatcher) PrepareInitialHandshake() { w.log.Debugf("enable WireGuard watcher") - w.enabled = true - w.muEnabled.Unlock() - handshake, _ := w.wgState() w.initialHandshake = handshake - return true } // EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by @@ -76,10 +64,6 @@ func (w *WGWatcher) PrepareInitialHandshake() (ok bool) { // handshake, including the first. func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) { w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake) - - w.muEnabled.Lock() - w.enabled = false - w.muEnabled.Unlock() } // Reset signals the watcher that the WireGuard peer has been reset and a new @@ -105,6 +89,7 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn case <-timer.C: handshake, ok := w.handshakeCheck(lastHandshake) if !ok { + // early ctx cancel check return if ctx.Err() != nil { return } @@ -153,9 +138,9 @@ func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) { w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake) - // the current know handshake did not change + // the current known handshake did not change if handshake.Equal(lastHandshake) { - w.log.Warnf("WireGuard handshake timed out: %v", handshake) + w.log.Warnf("WireGuard handshake not updated: %v", handshake) return nil, false } diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index 80f34f1a1..6a5a9acfe 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -7,7 +7,6 @@ import ( "time" log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/iface/configurer" ) @@ -62,7 +61,7 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - require.True(t, watcher.PrepareInitialHandshake()) + watcher.PrepareInitialHandshake() firstHandshake := make(chan struct{}, 1) checkSuccess := make(chan struct{}, 1) @@ -101,8 +100,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ok := watcher.PrepareInitialHandshake() - require.True(t, ok, "watcher should not be enabled yet") + watcher.PrepareInitialHandshake() onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { @@ -132,8 +130,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{})) ctx, cancel := context.WithCancel(context.Background()) - ok := watcher.PrepareInitialHandshake() - require.True(t, ok, "watcher should not be enabled yet") + watcher.PrepareInitialHandshake() wg := &sync.WaitGroup{} wg.Add(1) @@ -149,8 +146,7 @@ func TestWGWatcher_ReEnable(t *testing.T) { ctx, cancel = context.WithCancel(context.Background()) defer cancel() - ok = watcher.PrepareInitialHandshake() - require.True(t, ok, "watcher should be re-enabled after the previous run stopped") + watcher.PrepareInitialHandshake() onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { From a48618c074d2d072114ea463a72dba66c84094d1 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:12:37 +0200 Subject: [PATCH 14/14] [client] Fix forwarder peers never excluded from lazy connections (#6674) * [client] Extract peerRoutesAddr helper in toExcludedLazyPeers Refactor: pull the AllowedIPs match into a named peerRoutesAddr helper and document why forward-target peers are excluded from lazy connections. No behavior change; the existing address match is preserved as-is. * [client] Add failing test for lazy-conn forward-target exclusion toExcludedLazyPeers compares AllowedIPs (CIDR) against the unmasked TranslatedAddress, so forward-target peers are never excluded. This test asserts the peer is excluded and fails on the current behavior; the fix follows. * [client] Fix lazy-conn exclusion for ingress forward peers peerRoutesAddr compared AllowedIPs (CIDR, e.g. a peer's overlay IP as /32) against the unmasked TranslatedAddress string, so the match never fired and forward-target peers were never excluded from lazy connections. Use prefix containment so a routed address matches the peer's AllowedIP * [client] Reuse parsed AllowedIPs from peerStore in lazy exclusion Instead of re-parsing the network map AllowedIPs strings, look up the already-parsed []netip.Prefix from peerStore.AllowedIPs (the same typed value the lazy manager itself consumes). A down/lazy peer still has its conn in the store, so exclusion is unaffected by connection state. Extract a pure prefixesContain helper and unit-test it. --- client/internal/engine.go | 32 ++++++-- client/internal/engine_lazy_exclude_test.go | 87 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 client/internal/engine_lazy_exclude_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index 7b2fc7b26..1d00ed0d2 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -2605,13 +2605,14 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { excludedPeers := make(map[string]bool) + + // Ingress forward targets: inbound forwarded traffic is initiated remotely and + // cannot wake a lazy connection, so the peer routing the target must stay + // permanently connected. AllowedIPs are already parsed on the peer conn, so + // reuse those typed prefixes instead of re-parsing the network map strings. for _, r := range rules { - ip := r.TranslatedAddress for _, p := range peers { - for _, allowedIP := range p.GetAllowedIps() { - if allowedIP != ip.String() { - continue - } + if e.peerRoutesAddr(p, r.TranslatedAddress) { log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) excludedPeers[p.GetWgPubKey()] = true } @@ -2621,6 +2622,27 @@ func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers return excludedPeers } +// peerRoutesAddr reports whether the peer is a router for addr, matched against +// the peer's already-parsed AllowedIPs from the store (the same typed value the +// lazy manager consumes) rather than re-parsing the network map strings. +func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool { + prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey()) + if !ok { + return false + } + return prefixesContain(prefixes, addr) +} + +// prefixesContain reports whether addr falls within any of the prefixes. +func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool { + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + // isChecksEqual checks if two slices of checks are equal. func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool { normalize := func(checks []*mgmProto.Checks) []string { diff --git a/client/internal/engine_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go new file mode 100644 index 000000000..b5ef16c3b --- /dev/null +++ b/client/internal/engine_lazy_exclude_test.go @@ -0,0 +1,87 @@ +package internal + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestPrefixesContain(t *testing.T) { + tests := []struct { + name string + prefixes []string + addr string + want bool + }{ + {name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true}, + {name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true}, + {name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false}, + {name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false}, + {name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true}, + {name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefixes := make([]netip.Prefix, 0, len(tt.prefixes)) + for _, p := range tt.prefixes { + prefixes = append(prefixes, netip.MustParsePrefix(p)) + } + require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr))) + }) + } +} + +// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target +// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from +// lazy connections, matched via the peer's already-parsed AllowedIPs. +func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) { + const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0=" + const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0=" + + store := peerstore.NewConnStore() + store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32")) + store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32")) + + e := &Engine{peerStore: store} + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}}, + {WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}}, + } + rules := []firewallManager.ForwardRule{ + {TranslatedAddress: netip.MustParseAddr("100.110.8.145")}, + } + + excluded := e.toExcludedLazyPeers(rules, peers) + + require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections") + require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded") + require.Len(t, excluded, 1) +} + +func TestToExcludedLazyPeers_NoRules(t *testing.T) { + e := &Engine{peerStore: peerstore.NewConnStore()} + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}}, + } + + require.Empty(t, e.toExcludedLazyPeers(nil, peers)) +} + +func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn { + t.Helper() + conn, err := peer.NewConn(peer.ConnConfig{ + Key: key, + WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}}, + }, peer.ServiceDependencies{}) + require.NoError(t, err) + return conn +}