diff --git a/docs/windows/policy/en-US/tailscale.adml b/docs/windows/policy/en-US/tailscale.adml
index 8c375ca45..eb6a520d1 100644
--- a/docs/windows/policy/en-US/tailscale.adml
+++ b/docs/windows/policy/en-US/tailscale.adml
@@ -109,6 +109,14 @@ If you enable this policy setting, users will not be allowed to disconnect Tails
If necessary, it can be used along with Unattended Mode to keep Tailscale connected regardless of whether a user is logged in. This can be used to facilitate remote access to a device or ensure connectivity to a Domain Controller before a user logs in.
If you disable or don't configure this policy setting, users will be allowed to disconnect Tailscale at their will.]]>
+ Configure automatic reconnect delay
+ Allow Local Network Access when an Exit Node is in useThe options below allow configuring exceptions where disconnecting Tailscale is permitted.
Disconnects with reason:
+
+ The delay must be a valid Go duration string, such as 30s, 5m, or 1h30m, all without spaces or any other symbols.
+
+
+
+
diff --git a/docs/windows/policy/tailscale.admx b/docs/windows/policy/tailscale.admx
index 6a1ebc666..0ff311b40 100644
--- a/docs/windows/policy/tailscale.admx
+++ b/docs/windows/policy/tailscale.admx
@@ -156,6 +156,13 @@
+
+
+
+
+
+
+
diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go
index bd5f595be..fec5c166f 100644
--- a/ipn/ipnlocal/local.go
+++ b/ipn/ipnlocal/local.go
@@ -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)
diff --git a/util/syspolicy/policy_keys.go b/util/syspolicy/policy_keys.go
index a955ce094..a81c1e5d5 100644
--- a/util/syspolicy/policy_keys.go
+++ b/util/syspolicy/policy_keys.go
@@ -42,6 +42,12 @@ const (
// for auditing purposes. It has no effect when [AlwaysOn] is false.
AlwaysOnOverrideWithReason Key = "AlwaysOn.OverrideWithReason"
+ // ReconnectAfter is a string value formatted for use with time.ParseDuration()
+ // that defines the duration after which the client should automatically reconnect
+ // to the Tailscale network following a user-initiated disconnect.
+ // An empty string or a zero duration disables automatic reconnection.
+ ReconnectAfter Key = "ReconnectAfter"
+
// ExitNodeID is the exit node's node id. default ""; if blank, no exit node is forced.
// Exit node ID takes precedence over exit node IP.
// To find the node ID, go to /api.md#device.
@@ -176,6 +182,7 @@ var implicitDefinitions = []*setting.Definition{
setting.NewDefinition(LogTarget, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(MachineCertificateSubject, setting.DeviceSetting, setting.StringValue),
setting.NewDefinition(PostureChecking, setting.DeviceSetting, setting.PreferenceOptionValue),
+ setting.NewDefinition(ReconnectAfter, setting.DeviceSetting, setting.DurationValue),
setting.NewDefinition(Tailnet, setting.DeviceSetting, setting.StringValue),
// User policy settings (can be configured on a user- or device-basis):