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 use The 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):