various: keep tailscale connected when Always On mode is enabled on Windows

In this PR, we enable the registration of LocalBackend extensions to exclude code specific to certain
platforms or environments. We then introduce desktopSessionsExt, which is included only in Windows builds
and only if the ts_omit_desktop_sessions tag is disabled for the build. This extension tracks desktop sessions
and switches to (or remains on) the appropriate profile when a user signs in or out, locks their screen,
or disconnects a remote session.

As desktopSessionsExt requires an ipn/desktop.SessionManager, we register it with tsd.System
for the tailscaled subprocess on Windows.

We also fix a bug in the sessionWatcher implementation where it attempts to close a nil channel on stop.

Updates #14823
Updates tailscale/corp#26247

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-02-13 16:46:24 -06:00 committed by Nick Khyl
parent e11ff28443
commit 6df5c8f32e
9 changed files with 313 additions and 12 deletions

View File

@ -814,6 +814,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/ipn/desktop from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/ipnlocal from tailscale.com/ipn/localapi+
tailscale.com/ipn/ipnstate from tailscale.com/client/local+

View File

@ -272,6 +272,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+
💣 tailscale.com/ipn/desktop from tailscale.com/cmd/tailscaled+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/ipnlocal from tailscale.com/cmd/tailscaled+
tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled

View File

@ -44,6 +44,7 @@ import (
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"tailscale.com/drive/driveimpl"
"tailscale.com/envknob"
"tailscale.com/ipn/desktop"
"tailscale.com/logpolicy"
"tailscale.com/logtail/backoff"
"tailscale.com/net/dns"
@ -335,6 +336,13 @@ func beWindowsSubprocess() bool {
sys.Set(driveimpl.NewFileSystemForRemote(log.Printf))
if sessionManager, err := desktop.NewSessionManager(log.Printf); err == nil {
sys.Set(sessionManager)
} else {
// Errors creating the session manager are unexpected, but not fatal.
log.Printf("[unexpected]: error creating a desktop session manager: %v", err)
}
publicLogID, _ := logid.ParsePublicID(logID)
err = startIPNServer(ctx, log.Printf, publicLogID, sys)
if err != nil {

View File

@ -359,7 +359,7 @@ func (sw *sessionWatcher) Start() error {
sw.doneCh = make(chan error, 1)
startedCh := make(chan error, 1)
go sw.run(startedCh)
go sw.run(startedCh, sw.doneCh)
if err := <-startedCh; err != nil {
return err
}
@ -372,11 +372,11 @@ func (sw *sessionWatcher) Start() error {
return nil
}
func (sw *sessionWatcher) run(started chan<- error) {
func (sw *sessionWatcher) run(started, done chan<- error) {
runtime.LockOSThread()
defer func() {
runtime.UnlockOSThread()
close(sw.doneCh)
close(done)
}()
err := sw.createMessageWindow()
started <- err

View File

@ -0,0 +1,178 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Both the desktop session manager and multi-user support
// are currently available only on Windows.
// This file does not need to be built for other platforms.
//go:build windows && !ts_omit_desktop_sessions
package ipnlocal
import (
"cmp"
"errors"
"fmt"
"sync"
"tailscale.com/feature"
"tailscale.com/ipn"
"tailscale.com/ipn/desktop"
"tailscale.com/tsd"
"tailscale.com/types/logger"
"tailscale.com/util/syspolicy"
)
func init() {
feature.Register("desktop-sessions")
RegisterExtension("desktop-sessions", newDesktopSessionsExt)
}
// desktopSessionsExt implements [localBackendExtension].
var _ localBackendExtension = (*desktopSessionsExt)(nil)
// desktopSessionsExt extends [LocalBackend] with desktop session management.
// It keeps Tailscale running in the background if Always-On mode is enabled,
// and switches to an appropriate profile when a user signs in or out,
// locks their screen, or disconnects a remote session.
type desktopSessionsExt struct {
logf logger.Logf
sm desktop.SessionManager
*LocalBackend // or nil, until Init is called
cleanup []func() // cleanup functions to call on shutdown
// mu protects all following fields.
// When both mu and [LocalBackend.mu] need to be taken,
// [LocalBackend.mu] must be taken before mu.
mu sync.Mutex
id2sess map[desktop.SessionID]*desktop.Session
}
// newDesktopSessionsExt returns a new [desktopSessionsExt],
// or an error if [desktop.SessionManager] is not available.
func newDesktopSessionsExt(logf logger.Logf, sys *tsd.System) (localBackendExtension, error) {
sm, ok := sys.SessionManager.GetOK()
if !ok {
return nil, errors.New("session manager is not available")
}
return &desktopSessionsExt{logf: logf, sm: sm, id2sess: make(map[desktop.SessionID]*desktop.Session)}, nil
}
// Init implements [localBackendExtension].
func (e *desktopSessionsExt) Init(lb *LocalBackend) (err error) {
e.LocalBackend = lb
unregisterResolver := lb.RegisterBackgroundProfileResolver(e.getBackgroundProfile)
unregisterSessionCb, err := e.sm.RegisterStateCallback(e.updateDesktopSessionState)
if err != nil {
unregisterResolver()
return fmt.Errorf("session callback registration failed: %w", err)
}
e.cleanup = []func(){unregisterResolver, unregisterSessionCb}
return nil
}
// updateDesktopSessionState is a [desktop.SessionStateCallback]
// invoked by [desktop.SessionManager] once for each existing session
// and whenever the session state changes. It updates the session map
// and switches to the best profile if necessary.
func (e *desktopSessionsExt) updateDesktopSessionState(session *desktop.Session) {
e.mu.Lock()
if session.Status != desktop.ClosedSession {
e.id2sess[session.ID] = session
} else {
delete(e.id2sess, session.ID)
}
e.mu.Unlock()
var action string
switch session.Status {
case desktop.ForegroundSession:
// The user has either signed in or unlocked their session.
// For remote sessions, this may also mean the user has connected.
// The distinction isn't important for our purposes,
// so let's always say "signed in".
action = "signed in to"
case desktop.BackgroundSession:
action = "locked"
case desktop.ClosedSession:
action = "signed out from"
default:
panic("unreachable")
}
maybeUsername, _ := session.User.Username()
userIdentifier := cmp.Or(maybeUsername, string(session.User.UserID()), "user")
reason := fmt.Sprintf("%s %s session %v", userIdentifier, action, session.ID)
e.SwitchToBestProfile(reason)
}
// getBackgroundProfile is a [profileResolver] that works as follows:
//
// If Always-On mode is disabled, it returns no profile ("","",false).
//
// If AlwaysOn mode is enabled, it returns the current profile unless:
// - The current user has signed out.
// - Another user has a foreground (i.e. active/unlocked) session.
//
// If the current user's session runs in the background and no other user
// has a foreground session, it returns the current profile. This applies
// when a locally signed-in user locks their screen or when a remote user
// disconnects without signing out.
//
// In all other cases, it returns no profile ("","",false).
//
// It is called with [LocalBackend.mu] locked.
func (e *desktopSessionsExt) getBackgroundProfile() (_ ipn.WindowsUserID, _ ipn.ProfileID, ok bool) {
e.mu.Lock()
defer e.mu.Unlock()
if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); !alwaysOn {
return "", "", false
}
isCurrentUserSingedIn := false
var foregroundUIDs []ipn.WindowsUserID
for _, s := range e.id2sess {
switch uid := s.User.UserID(); uid {
case e.pm.CurrentUserID():
isCurrentUserSingedIn = true
if s.Status == desktop.ForegroundSession {
// Keep the current profile if the user has a foreground session.
return e.pm.CurrentUserID(), e.pm.CurrentProfile().ID(), true
}
default:
if s.Status == desktop.ForegroundSession {
foregroundUIDs = append(foregroundUIDs, uid)
}
}
}
// If there's no current user (e.g., tailscaled just started), or if the current
// user has no foreground session, switch to the default profile of the first user
// with a foreground session, if any.
for _, uid := range foregroundUIDs {
if profileID := e.pm.DefaultUserProfileID(uid); profileID != "" {
return uid, profileID, true
}
}
// If no user has a foreground session but the current user is still signed in,
// keep the current profile even if the session is not in the foreground,
// such as when the screen is locked or a remote session is disconnected.
if len(foregroundUIDs) == 0 && isCurrentUserSingedIn {
return e.pm.CurrentUserID(), e.pm.CurrentProfile().ID(), true
}
return "", "", false
}
// Shutdown implements [localBackendExtension].
func (e *desktopSessionsExt) Shutdown() error {
for _, f := range e.cleanup {
f()
}
e.cleanup = nil
e.LocalBackend = nil
return nil
}

View File

@ -168,6 +168,49 @@ type watchSession struct {
cancel context.CancelFunc // to shut down the session
}
// localBackendExtension extends [LocalBackend] with additional functionality.
type localBackendExtension interface {
// Init is called to initialize the extension when the [LocalBackend] is created
// and before it starts running. If the extension cannot be initialized,
// it must return an error, and the Shutdown method will not be called.
// Any returned errors are not fatal; they are used for logging.
// TODO(nickkhyl): should we allow returning a fatal error?
Init(*LocalBackend) error
// Shutdown is called when the [LocalBackend] is shutting down,
// if the extension was initialized. Any returned errors are not fatal;
// they are used for logging.
Shutdown() error
}
// newLocalBackendExtension is a function that instantiates a [localBackendExtension].
type newLocalBackendExtension func(logger.Logf, *tsd.System) (localBackendExtension, error)
// registeredExtensions is a map of registered local backend extensions,
// where the key is the name of the extension and the value is the function
// that instantiates the extension.
var registeredExtensions map[string]newLocalBackendExtension
// RegisterExtension registers a function that creates a [localBackendExtension].
// It panics if newExt is nil or if an extension with the same name has already been registered.
func RegisterExtension(name string, newExt newLocalBackendExtension) {
if newExt == nil {
panic(fmt.Sprintf("lb: newExt is nil: %q", name))
}
if _, ok := registeredExtensions[name]; ok {
panic(fmt.Sprintf("lb: duplicate extensions: %q", name))
}
mak.Set(&registeredExtensions, name, newExt)
}
// profileResolver is any function that returns user and profile IDs
// along with a flag indicating whether it succeeded. Since an empty
// profile ID ("") represents an empty profile, the ok return parameter
// distinguishes between an empty profile and no profile.
//
// It is called with [LocalBackend.mu] held.
type profileResolver func() (_ ipn.WindowsUserID, _ ipn.ProfileID, ok bool)
// LocalBackend is the glue between the major pieces of the Tailscale
// network software: the cloud control plane (via controlclient), the
// network data plane (via wgengine), and the user-facing UIs and CLIs
@ -302,8 +345,12 @@ type LocalBackend struct {
directFileRoot string
componentLogUntil map[string]componentLogState
// c2nUpdateStatus is the status of c2n-triggered client update.
c2nUpdateStatus updateStatus
currentUser ipnauth.Actor
c2nUpdateStatus updateStatus
currentUser ipnauth.Actor
// backgroundProfileResolvers are optional background profile resolvers.
backgroundProfileResolvers set.HandleSet[profileResolver]
selfUpdateProgress []ipnstate.UpdateProgress
lastSelfUpdateState ipnstate.SelfUpdateStatus
// capForcedNetfilter is the netfilter that control instructs Linux clients
@ -394,6 +441,11 @@ type LocalBackend struct {
// and the user has disconnected with a reason.
// See tailscale/corp#26146.
overrideAlwaysOn bool
// 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.
shutdownCbs set.HandleSet[func() error]
}
// HealthTracker returns the health tracker for the backend.
@ -575,6 +627,19 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
}
}
for name, newFn := range registeredExtensions {
ext, err := newFn(logf, sys)
if err != nil {
b.logf("lb: failed to create %q extension: %v", name, err)
continue
}
if err := ext.Init(b); err != nil {
b.logf("lb: failed to initialize %q extension: %v", name, err)
continue
}
b.shutdownCbs.Add(ext.Shutdown)
}
return b, nil
}
@ -1033,9 +1098,17 @@ func (b *LocalBackend) Shutdown() {
if b.notifyCancel != nil {
b.notifyCancel()
}
shutdownCbs := slices.Collect(maps.Values(b.shutdownCbs))
b.shutdownCbs = nil
b.mu.Unlock()
b.webClientShutdown()
for _, cb := range shutdownCbs {
if err := cb(); err != nil {
b.logf("shutdown callback failed: %v", err)
}
}
if b.sockstatLogger != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -3826,13 +3899,18 @@ func (b *LocalBackend) SetCurrentUser(actor ipnauth.Actor) {
b.switchToBestProfileLockedOnEntry(reason, unlock)
}
// switchToBestProfileLockedOnEntry selects the best profile to use,
// SwitchToBestProfile selects the best profile to use,
// as reported by [LocalBackend.resolveBestProfileLocked], and switches
// to it, unless it's already the current profile. The reason indicates
// why the profile is being switched, such as due to a client connecting
// or disconnecting and is used for logging.
//
// b.mu must held on entry. It is released on exit.
// or disconnecting, or a change in the desktop session state, and is used
// for logging.
func (b *LocalBackend) SwitchToBestProfile(reason string) {
b.switchToBestProfileLockedOnEntry(reason, b.lockAndGetUnlock())
}
// switchToBestProfileLockedOnEntry is like [LocalBackend.SwitchToBestProfile],
// but b.mu must held on entry. It is released on exit.
func (b *LocalBackend) switchToBestProfileLockedOnEntry(reason string, unlock unlockOnce) {
defer unlock()
oldControlURL := b.pm.CurrentPrefs().ControlURLOrDefault()
@ -3867,8 +3945,9 @@ func (b *LocalBackend) switchToBestProfileLockedOnEntry(reason string, unlock un
}
// resolveBestProfileLocked returns the best profile to use based on the current
// state of the backend, such as whether a GUI/CLI client is connected and whether
// the unattended mode is enabled.
// state of the backend, such as whether a GUI/CLI client is connected, whether
// the unattended mode is enabled, the current state of the desktop sessions,
// and other factors.
//
// It returns the user ID, profile ID, and whether the returned profile is
// considered a background profile. A background profile is used when no OS user
@ -3897,7 +3976,8 @@ func (b *LocalBackend) resolveBestProfileLocked() (userID ipn.WindowsUserID, pro
}
// Otherwise, if on Windows, use the background profile if one is set.
// This includes staying on the current profile if Unattended Mode is enabled.
// This includes staying on the current profile if Unattended Mode is enabled
// or if AlwaysOn mode is enabled and the current user is still signed in.
// If the returned background profileID is "", Tailscale will disconnect
// and remain idle until a GUI or CLI client connects.
if goos := envknob.GOOS(); goos == "windows" {
@ -3914,14 +3994,41 @@ func (b *LocalBackend) resolveBestProfileLocked() (userID ipn.WindowsUserID, pro
return b.pm.CurrentUserID(), b.pm.CurrentProfile().ID(), false
}
// RegisterBackgroundProfileResolver registers a function to be used when
// resolving the background profile, until the returned unregister function is called.
func (b *LocalBackend) RegisterBackgroundProfileResolver(resolver profileResolver) (unregister func()) {
// TODO(nickkhyl): should we allow specifying some kind of priority/altitude for the resolver?
b.mu.Lock()
defer b.mu.Unlock()
handle := b.backgroundProfileResolvers.Add(resolver)
return func() {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.backgroundProfileResolvers, handle)
}
}
// getBackgroundProfileLocked returns the user and profile ID to use when no GUI/CLI
// client is connected, or "","" if Tailscale should not run in the background.
// As of 2025-02-07, it is only used on Windows.
func (b *LocalBackend) getBackgroundProfileLocked() (ipn.WindowsUserID, ipn.ProfileID) {
// TODO(nickkhyl): check if the returned profile is allowed on the device,
// such as when [syspolicy.Tailnet] policy setting requires a specific Tailnet.
// See tailscale/corp#26249.
// If Unattended Mode is enabled for the current profile, keep using it.
if b.pm.CurrentPrefs().ForceDaemon() {
return b.pm.CurrentProfile().LocalUserID(), b.pm.CurrentProfile().ID()
}
// Otherwise, attempt to resolve the background profile using the background
// profile resolvers available on the current platform.
for _, resolver := range b.backgroundProfileResolvers {
if uid, profileID, ok := resolver(); ok {
return uid, profileID
}
}
// Otherwise, switch to an empty profile and disconnect Tailscale
// until a GUI or CLI client connects.
return "", ""

View File

@ -81,6 +81,7 @@ func actorWithAccessOverride(baseActor *actor, reason string) *actor {
logf: baseActor.logf,
ci: baseActor.ci,
clientID: baseActor.clientID,
userID: baseActor.userID,
accessOverrideReason: reason,
isLocalSystem: baseActor.isLocalSystem,
}

View File

@ -26,6 +26,7 @@ import (
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/conffile"
"tailscale.com/ipn/desktop"
"tailscale.com/net/dns"
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
@ -52,6 +53,7 @@ type System struct {
Netstack SubSystem[NetstackImpl] // actually a *netstack.Impl
DriveForLocal SubSystem[drive.FileSystemForLocal]
DriveForRemote SubSystem[drive.FileSystemForRemote]
SessionManager SubSystem[desktop.SessionManager]
// InitialConfig is initial server config, if any.
// It is nil if the node is not in declarative mode.
@ -110,6 +112,8 @@ func (s *System) Set(v any) {
s.DriveForLocal.Set(v)
case drive.FileSystemForRemote:
s.DriveForRemote.Set(v)
case desktop.SessionManager:
s.SessionManager.Set(v)
default:
panic(fmt.Sprintf("unknown type %T", v))
}

View File

@ -29,6 +29,7 @@ import (
_ "tailscale.com/hostinfo"
_ "tailscale.com/ipn"
_ "tailscale.com/ipn/conffile"
_ "tailscale.com/ipn/desktop"
_ "tailscale.com/ipn/ipnlocal"
_ "tailscale.com/ipn/ipnserver"
_ "tailscale.com/ipn/store"