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