ipn/ipnlocal,util/syspolicy,docs/windows/policy: implement the ReconnectAfter policy setting

In this PR, we update the LocalBackend so that when the ReconnectAfter policy setting is configured
and a user disconnects Tailscale by setting WantRunning to false in the profile prefs, the LocalBackend
will now start a timer to set WantRunning back to true once the ReconnectAfter timer expires.

We also update the ADMX/ADML policy definitions to allow configuring this policy setting for Windows
via Group Policy and Intune.

Updates #14824

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl
2025-02-24 14:50:04 -06:00
committed by Nick Khyl
parent d1b0e1af06
commit 8d7033fe7f
4 changed files with 97 additions and 3 deletions

View File

@@ -442,6 +442,10 @@ type LocalBackend struct {
// See tailscale/corp#26146.
overrideAlwaysOn bool
// reconnectTimer is used to schedule a reconnect by setting [ipn.Prefs.WantRunning]
// to true after a delay, or nil if no reconnect is scheduled.
reconnectTimer tstime.TimerController
// shutdownCbs are the callbacks to be called when the backend is shutting down.
// Each callback is called exactly once in unspecified order and without b.mu held.
// Returned errors are logged but otherwise ignored and do not affect the shutdown process.
@@ -1070,6 +1074,8 @@ func (b *LocalBackend) Shutdown() {
b.captiveCancel()
}
b.stopReconnectTimerLocked()
if b.loginFlags&controlclient.LoginEphemeral != 0 {
b.mu.Unlock()
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
@@ -4297,15 +4303,75 @@ func (b *LocalBackend) EditPrefsAs(mp *ipn.MaskedPrefs, actor ipnauth.Actor) (ip
// mode on them until the policy changes, they switch to a different profile, etc.
b.overrideAlwaysOn = true
// TODO(nickkhyl): check the ReconnectAfter policy here. If configured,
// start a timer to automatically reconnect after the specified duration.
if reconnectAfter, _ := syspolicy.GetDuration(syspolicy.ReconnectAfter, 0); reconnectAfter > 0 {
b.startReconnectTimerLocked(reconnectAfter)
}
}
return b.editPrefsLockedOnEntry(mp, unlock)
}
// startReconnectTimerLocked sets a timer to automatically set WantRunning to true
// after the specified duration.
func (b *LocalBackend) startReconnectTimerLocked(d time.Duration) {
if b.reconnectTimer != nil {
// Stop may return false if the timer has already fired,
// and the function has been called in its own goroutine,
// but lost the race to acquire b.mu. In this case, it'll
// end up as a no-op due to a reconnectTimer mismatch
// once it manages to acquire the lock. This is fine, and we
// don't need to check the return value.
b.reconnectTimer.Stop()
}
profileID := b.pm.CurrentProfile().ID()
var reconnectTimer tstime.TimerController
reconnectTimer = b.clock.AfterFunc(d, func() {
unlock := b.lockAndGetUnlock()
defer unlock()
if b.reconnectTimer != reconnectTimer {
// We're either not the most recent timer, or we lost the race when
// the timer was stopped. No need to reconnect.
return
}
b.reconnectTimer = nil
cp := b.pm.CurrentProfile()
if cp.ID() != profileID {
// The timer fired before the profile changed but we lost the race
// and acquired the lock shortly after.
// No need to reconnect.
return
}
mp := &ipn.MaskedPrefs{WantRunningSet: true, Prefs: ipn.Prefs{WantRunning: true}}
if _, err := b.editPrefsLockedOnEntry(mp, unlock); err != nil {
b.logf("failed to automatically reconnect as %q after %v: %v", cp.Name(), d, err)
} else {
b.logf("automatically reconnected as %q after %v", cp.Name(), d)
}
})
b.reconnectTimer = reconnectTimer
b.logf("reconnect for %q has been scheduled and will be performed in %v", b.pm.CurrentProfile().Name(), d)
}
func (b *LocalBackend) resetAlwaysOnOverrideLocked() {
b.overrideAlwaysOn = false
b.stopReconnectTimerLocked()
}
func (b *LocalBackend) stopReconnectTimerLocked() {
if b.reconnectTimer != nil {
// Stop may return false if the timer has already fired,
// and the function has been called in its own goroutine,
// but lost the race to acquire b.mu.
// In this case, it'll end up as a no-op due to a reconnectTimer
// mismatch (see [LocalBackend.startReconnectTimerLocked])
// once it manages to acquire the lock. This is fine, and we
// don't need to check the return value.
b.reconnectTimer.Stop()
b.reconnectTimer = nil
}
}
// Warning: b.mu must be held on entry, but it unlocks it on the way out.
@@ -4399,7 +4465,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
if oldp.Valid() {
newp.Persist = oldp.Persist().AsStruct() // caller isn't allowed to override this
}
// applySysPolicyToPrefsLocked returns whether it updated newp,
// applySysPolicy returns whether it updated newp,
// but everything in this function treats b.prefs as completely new
// anyway, so its return value can be ignored here.
applySysPolicy(newp, b.lastSuggestedExitNode, b.overrideAlwaysOn)