From 9992b7c817676e964a30a168a2d4d921f5c09ac2 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:06:20 -0700 Subject: [PATCH] ipn,ipn/local: broadcast ClientVersion if AutoUpdate.Check (#19107) If AutoUpdate.Check is false, the client has opted out of checking for updates, so we shouldn't broadcast ClientVersion. If the client has opted in, it should be included in the initial Notify. Updates tailscale/corp#32629 Signed-off-by: kari-ts --- ipn/backend.go | 2 + ipn/ipnlocal/local.go | 20 +++++++-- ipn/ipnlocal/local_test.go | 92 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/ipn/backend.go b/ipn/backend.go index 3183c8b5e..a5830565b 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -85,6 +85,8 @@ const ( NotifyHealthActions NotifyWatchOpt = 1 << 9 // if set, include PrimaryActions in health.State. Otherwise append the action URL to the text NotifyInitialSuggestedExitNode NotifyWatchOpt = 1 << 10 // if set, the first Notify message (sent immediately) will contain the current SuggestedExitNode if available + + NotifyInitialClientVersion NotifyWatchOpt = 1 << 11 // if set, the first Notify message (sent immediately) will contain the current ClientVersion if available and if update checks are enabled ) // Notify is a communication from a backend (e.g. tailscaled) to a frontend diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 28fb48fa6..49e1f00c7 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -3143,7 +3143,7 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A b.mu.Lock() - const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode + const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | ipn.NotifyInitialClientVersion if mask&initialBits != 0 { cn := b.currentNode() ini = &ipn.Notify{Version: version.Long()} @@ -3171,6 +3171,11 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A ini.SuggestedExitNode = &en.ID } } + if mask&ipn.NotifyInitialClientVersion != 0 { + if prefs := b.pm.CurrentPrefs(); prefs.Valid() && prefs.AutoUpdate().Check { + ini.ClientVersion = b.lastClientVersion + } + } } ctx, cancel := context.WithCancel(ctx) @@ -3551,10 +3556,13 @@ func (b *LocalBackend) tellRecipientToBrowseToURLLocked(url string, recipient no // a non-nil ClientVersion message. func (b *LocalBackend) onClientVersion(v *tailcfg.ClientVersion) { b.mu.Lock() + defer b.mu.Unlock() b.lastClientVersion = v b.health.SetLatestVersion(v) - b.mu.Unlock() - b.send(ipn.Notify{ClientVersion: v}) + prefs := b.pm.CurrentPrefs() + if prefs.Valid() && prefs.AutoUpdate().Check { + b.sendLocked(ipn.Notify{ClientVersion: v}) + } } func (b *LocalBackend) onTailnetDefaultAutoUpdate(au bool) { @@ -4757,6 +4765,12 @@ func (b *LocalBackend) setPrefsLocked(newp *ipn.Prefs) ipn.PrefsView { b.authReconfigLocked() } + if newp.AutoUpdate.Check && !oldp.AutoUpdate().Check { + if cv := b.lastClientVersion; cv != nil { + b.sendLocked(ipn.Notify{ClientVersion: cv}) + } + } + b.sendLocked(ipn.Notify{Prefs: &prefs}) return prefs } diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index b58fb2945..317275567 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -7400,6 +7400,98 @@ func TestDeps(t *testing.T) { }.Check(t) } +func TestOnClientVersionRespectsAutoUpdateCheck(t *testing.T) { + lb := newTestLocalBackend(t) + + cv := &tailcfg.ClientVersion{ + RunningLatest: false, + LatestVersion: "1.96.0", + } + + // With Check disabled, onClientVersion should cache but not broadcast. + lb.SetPrefsForTest(&ipn.Prefs{ + AutoUpdate: ipn.AutoUpdatePrefs{Check: false}, + }) + + nw := newNotificationWatcher(t, lb, ipnauth.Self) + nw.watch(0, nil, unexpectedClientVersion) + lb.onClientVersion(cv) + nw.check() + + // Verify it was cached despite not being broadcast. + lb.mu.Lock() + cached := lb.lastClientVersion + lb.mu.Unlock() + if cached == nil || cached.LatestVersion != "1.96.0" { + t.Fatalf("lastClientVersion not cached: got %v", cached) + } + + // With Check enabled, onClientVersion should broadcast. + lb.SetPrefsForTest(&ipn.Prefs{ + AutoUpdate: ipn.AutoUpdatePrefs{Check: true}, + }) + + nw.watch(0, []wantedNotification{ + wantClientVersionNotify("1.96.0"), + }) + lb.onClientVersion(cv) + nw.check() +} + +func TestWatchNotificationsInitialClientVersion(t *testing.T) { + lb := newTestLocalBackend(t) + + cv := &tailcfg.ClientVersion{ + RunningLatest: false, + LatestVersion: "1.96.0", + } + + // Set Check=true and cache a ClientVersion. + lb.SetPrefsForTest(&ipn.Prefs{ + AutoUpdate: ipn.AutoUpdatePrefs{Check: true}, + }) + lb.mu.Lock() + lb.lastClientVersion = cv + lb.mu.Unlock() + + // Watch with NotifyInitialClientVersion should include ClientVersion. + nw := newNotificationWatcher(t, lb, ipnauth.Self) + nw.watch(ipn.NotifyInitialClientVersion, []wantedNotification{ + wantClientVersionNotify("1.96.0"), + }) + nw.check() + + // Watch without the flag, should not include it. + nw2 := newNotificationWatcher(t, lb, ipnauth.Self) + nw2.watch(0, nil, unexpectedClientVersion) + nw2.check() + + // Watch with the flag but Check=false, should not include it. + lb.SetPrefsForTest(&ipn.Prefs{ + AutoUpdate: ipn.AutoUpdatePrefs{Check: false}, + }) + nw3 := newNotificationWatcher(t, lb, ipnauth.Self) + nw3.watch(ipn.NotifyInitialClientVersion, nil, unexpectedClientVersion) + nw3.check() +} + +func wantClientVersionNotify(wantLatest string) wantedNotification { + return wantedNotification{ + name: fmt.Sprintf("ClientVersion-%s", wantLatest), + cond: func(_ testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool { + return n.ClientVersion != nil && n.ClientVersion.LatestVersion == wantLatest + }, + } +} + +func unexpectedClientVersion(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool { + if n.ClientVersion != nil { + t.Errorf("unexpected ClientVersion: %v", n.ClientVersion) + return true + } + return false +} + func checkError(tb testing.TB, got, want error, fatal bool) { tb.Helper() f := tb.Errorf